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

Treat layout pages like components when using named slots #627

Open
Aias opened this issue Sep 5, 2019 · 71 comments
Open

Treat layout pages like components when using named slots #627

Aias opened this issue Sep 5, 2019 · 71 comments
Labels
feature request New feature or request svelte This feature can't be implemented without changes to Svelte itself
Milestone

Comments

@Aias
Copy link

Aias commented Sep 5, 2019

Let's say I have a _layout.svelte like the following:

<slot name="layout-pre"></slot>

<div id="intermediate">Some other intermediate stuff.</div>

<slot></slot>

And now I have a child view (let's call it homepage.svelte which inherits that layout, which puts some stuff into the named slot, and the rest into the default slot:

<h1 slot="layout-pre">This should come before #intermediate.</h1>

<!-- When rendered, the div from above should get inserted here from the layout:
<div id="intermediate">Some other intermediate stuff.</div>
-->

<main>Since this doesn't use a named slot, it'll just get inserted into the unnamed slot above.</main>

Right now this is not allowed, and the following error is thrown: Element with a slot='...' attribute must be a descendant of a component or custom element. Are there technical or usage reasons why we wouldn't want to treat a layout like any other component with respect to slots?

@joakim

This comment was marked as duplicate.

@buhrmi
Copy link

buhrmi commented Nov 4, 2019

I guess it doesn't work because the Svelte compiler needs to know at compile-time which parent-component the slots will end up in.

@jdgaravito

This comment was marked as off-topic.

@buhrmi
Copy link

buhrmi commented Feb 6, 2020

Well, there's nothing keeping us from writing our pages like

<Layout>
  <h1 slot="above">...</h1>
  <main>derp derp lalalala</main>
</Layout>

@silllli

This comment was marked as off-topic.

@jdgaravito

This comment was marked as off-topic.

@Bandit

This comment was marked as off-topic.

@zakaria-chahboun
Copy link

You have to do it in a regular way, by creating a custom layout: (_myLayout.svelte)
and include it in your index page.

@mrmaglet
Copy link

mrmaglet commented Feb 1, 2021

Can someone show how to include a _customLayout.svelte as zakaria-chahboun suggests?

@zakaria-chahboun
Copy link

Can someone show how to include a _customLayout.svelte as zakaria-chahboun suggests?

Example:

We have a route called test.
So we just create a folder named test, and inside of this folder we have our svelte files,
One is our router and the second is our custom layout, like so:

  • test
    • [id].svelte
    • _customLayout.svelte

The _customLayout.svelte is like this:

<slot name="sidebar"></slot>
<slot name="profile"></slot>

And the [id].svelte is like this:

<script context="module">
  export async function preload(page) {
    const { id } = page.params;
    return {id};
}
</script>

<script>
    import customLayout from "./_customLayout.svelte";
</script>

<customLayout>
    <div slot="sidebar">test</div>
</customLayout>

@mquandalle

This comment was marked as off-topic.

@benmccann benmccann transferred this issue from sveltejs/sapper Mar 24, 2021
@Nick-Mazuk

This comment was marked as off-topic.

@stalkerg

This comment was marked as off-topic.

@parischap

This comment was marked as off-topic.

@mvolfik mvolfik mentioned this issue May 17, 2021
@brandonbuttars

This comment was marked as off-topic.

@mohe2015

This comment was marked as off-topic.

@UltraCakeBakery
Copy link

UltraCakeBakery commented Jul 5, 2021

In the pseudocode snippet below I demonstrate how I get around this problem in my project.

<!-- ⚠ untested pseudo-ish code ahead ⚠  -->

<!-- __layout.svelte -->
<script context="module">
	export async function load({ page })
	{
		const slots = {}
		if( page.path === '/foo' )
		{
			slots.navigator = ( await import( `$lib/page-slot-components/foo/navigator.svelte`) ).default
			slots.sidemenu = ( await import( `$lib/page-slot-components/foo/sidemenu.svelte`) ).default
		}
		else
		{
			slots.navigator = ( await import( `$lib/page-slot-components/bar/navigator.svelte`) ).default
			slots.sidemenu = ( await import( `$lib/page-slot-components/bar/sidemenu.svelte`) ).default
		}

		return {
			status: 200,
			props: {
				slots
			}
		}
	}
</script>

<script>
	export let slots
</script>

{ #if slots.navigator }
	<svelte:component this={slots.navigator}></svelte:component>
{/if}

{ #if slots.sidemenu }
	<svelte:component this={slots.navigator}></svelte:component>
{/if}

This approach is fully SSR compatible and does not produce flashes of content. There are limitations though of course.

Note: svelte-kit currently isn't able to properly parse template literals that also use import aliases like $lib. Actually, import paths with variables in general have issues. So clever dynamic paths for your import() function like src/routes${page.path}/_Navigator.svelte are going to be difficult to pull of, but not impossible. I say give it a shot. There are many different ways to configure this until something like #1459 is implemented

@NickantX

This comment was marked as off-topic.

@newlegendmedia

This comment was marked as off-topic.

@UltraCakeBakery

This comment has been minimized.

@mohe2015

This comment has been minimized.

@stalkerg

This comment has been minimized.

@mohe2015

This comment was marked as off-topic.

@rogadev

This comment was marked as off-topic.

@AdaptingAFM

This comment was marked as off-topic.

@dmvvilela

This comment was marked as off-topic.

@fnimick

This comment was marked as off-topic.

@Rich-Harris
Copy link
Member

Marked the majority of comments as 'off-topic' because most of them boil down to '+1'.

Please resist the urge to add comments along the lines of 'I also want this' or 'why isn't this done yet' or 'here's my use case, which is basically identical to all the other use cases that have already been mentioned'! You're not just making the thread harder to follow without adding useful information, you're actually harming your cause — a simple 👍 reaction on the OP of a GitHub issue thread carries more weight than a redundant comment, because those reactions can be used as a sorting mechanism. (Granted, this is already the top-reacted thread.)

With that out of the way:

Why this isn't supported yet

Allowing named slots in layouts would require that we had a mechanism for 'passing' slots to components. Effectively we need to be able to represent this concept...

<aside><slot name="sidebar" /></aside>
<main><slot /></main>
<Layout data={...}>
  <div slot="sidebar">
    <nav>...</nav>
  </div>

  <p>the content</p>
</Layout>

...without knowing ahead of time what goes inside <Layout>:

<Layout data={...}>
  <Page $$slots={???} />
</Layout>

Right now, there's just no mechanism to do that in Svelte itself. It's something we'd like to add, but the bulk of our energy over the last year or so has been directed towards SvelteKit, and we're just now working through the backlog of Svelte PRs so that we're in a position to start thinking about major new features like this. It'll happen, but it won't happen overnight.

Why you probably don't need it anyway

$page.data contains all the data returned from your load functions, and allows you to use page data in a layout:

// src/routes/some/deeply/nested/+page.js
export function load() {
  return {
    message: 'hello from all the way down here'
  };
}
<!-- src/routes/+layout.svelte -->
<script>
  import { page } from '$app/stores';
</script>

<p>{$page.data.message ?? '...'}</p>

You can define App.PageData in your src/app.d.ts to provide type safety around $page.data.

If you're using universal load functions instead of (or alongside) server load functions — i.e. +page.js rather than +page.server.js — you can return components as well as data, which means you can easily do this sort of thing:

import links from './Links.svelte';

export function load() {
  return { links }
}
<nav>
  <!-- standard links -->
  <a href="...">...</a>

  <!-- page-specific links -->
  <svelte:component this={$page.data.links} />
</nav>

It's not quite as idiomatic as slot="links" would be, but it should give you all the power you need. Here's a demo.

@nazihahmed
Copy link

our solution to this problem is relatively simple,
you can opt out of the layout by adding @ to the page file name for example +page@.svelte
and then in the page you can do something like this

<script>
  export let data;
</script>
<Layout {data}>
  <div slot="x">
    <!-- Content for slot x -->
  </div>
  <div>
    <!-- main slot goes here -->
  </div>
</Layout>

and in the +layout.svelte use named slots like normal

<slot name="x" />
<div>
  <slot/>
</div>

hope this helps someone !

@enisbudancamanak
Copy link

@nazihahmed could you explain how it worked in your project? because unfortunately it doesn't work for me

@Cybolic
Copy link

Cybolic commented May 16, 2023

@nazihahmed Unfortunately, this will still inherit the root layout, so by default you'd get the layout inside itself.
One can work around this by having an empty root layout and then grouping the rest of the routes inside a group:

routes/+layout.svelte

<slot/>

routes/(haslayout)/+layout.svelte

<nav>
  Always-on nav stuff here
  <slot name="nav" />
</nav>
<div>
  <slot/>
</div>

routes/(haslayout)/routewithextranav/+page@.svelte <- this now uses the empty root layout

<script>
  import Layout from "$routes/(haslayout)/+layout.svelte";
  export let data;
</script>
<Layout {data}>
  <span slot="nav">
    Hey! More stuff in the nav!
  </span>
  <div>
    Rest of the page goes here.
  </div>
</Layout>

I find it somewhat annoying having to use a group like that, but it seems to be the easiest workaround for now.

@Palmik
Copy link

Palmik commented Jul 1, 2023

Just bike shedding here, but <svelte:head> like syntax, e.g. <svelte:slot name="xxx"> might make sense for this use case.

@DoisKoh
Copy link

DoisKoh commented Jul 13, 2023

@nazihahmed Unfortunately, this will still inherit the root layout, so by default you'd get the layout inside itself. One can work around this by having an empty root layout and then grouping the rest of the routes inside a group:

routes/+layout.svelte

<slot/>

routes/(haslayout)/+layout.svelte

<nav>
  Always-on nav stuff here
  <slot name="nav" />
</nav>
<div>
  <slot/>
</div>

routes/(haslayout)/routewithextranav/+page@.svelte <- this now uses the empty root layout

<script>
  import Layout from "$routes/(haslayout)/+layout.svelte";
  export let data;
</script>
<Layout {data}>
  <span slot="nav">
    Hey! More stuff in the nav!
  </span>
  <div>
    Rest of the page goes here.
  </div>
</Layout>

I find it somewhat annoying having to use a group like that, but it seems to be the easiest workaround for now.

How do you guys deal with the data or code that would normally get loaded in the +layout.server.ts? You would have to re-do all that in each+page.server.ts and pass it over to the imported Layout component right (which is clearly not an acceptable solution)?

I guess it would still be nice if there were a proper feature for this, for now I've gone with appending elements to the desired location via .appendChild(...).

@205g0
Copy link

205g0 commented Aug 14, 2023

FWIW, I had the solution in #627 (comment) for some time in my code until I introduced groups, e.g. (group1)/ and (grroup2)/ then this doesn't work anymore:

The link component works when your access group1 in your browser but once you navigate to group2, the link from both groups can be seen at the same time/kind of overlap.

Tried few work-arounds before I wrote this but couldn't find a good solution yet.

@jameswoodley
Copy link

jameswoodley commented Aug 23, 2023

Marked the majority of comments as 'off-topic' because most of them boil down to '+1'.

Please resist the urge to add comments along the lines of 'I also want this' or 'why isn't this done yet' or 'here's my use case, which is basically identical to all the other use cases that have already been mentioned'! You're not just making the thread harder to follow without adding useful information, you're actually harming your cause — a simple 👍 reaction on the OP of a GitHub issue thread carries more weight than a redundant comment, because those reactions can be used as a sorting mechanism. (Granted, this is already the top-reacted thread.)

With that out of the way:

Why this isn't supported yet

Allowing named slots in layouts would require that we had a mechanism for 'passing' slots to components. Effectively we need to be able to represent this concept...

<aside><slot name="sidebar" /></aside>
<main><slot /></main>
<Layout data={...}>
  <div slot="sidebar">
    <nav>...</nav>
  </div>

  <p>the content</p>
</Layout>

...without knowing ahead of time what goes inside <Layout>:

<Layout data={...}>
  <Page $$slots={???} />
</Layout>

Right now, there's just no mechanism to do that in Svelte itself. It's something we'd like to add, but the bulk of our energy over the last year or so has been directed towards SvelteKit, and we're just now working through the backlog of Svelte PRs so that we're in a position to start thinking about major new features like this. It'll happen, but it won't happen overnight.

Why you probably don't need it anyway

$page.data contains all the data returned from your load functions, and allows you to use page data in a layout:

// src/routes/some/deeply/nested/+page.js
export function load() {
  return {
    message: 'hello from all the way down here'
  };
}
<!-- src/routes/+layout.svelte -->
<script>
  import { page } from '$app/stores';
</script>

<p>{$page.data.message ?? '...'}</p>

You can define App.PageData in your src/app.d.ts to provide type safety around $page.data.

If you're using universal load functions instead of (or alongside) server load functions — i.e. +page.js rather than +page.server.js — you can return components as well as data, which means you can easily do this sort of thing:

import links from './Links.svelte';

export function load() {
  return { links }
}
<nav>
  <!-- standard links -->
  <a href="...">...</a>

  <!-- page-specific links -->
  <svelte:component this={$page.data.links} />
</nav>

It's not quite as idiomatic as slot="links" would be, but it should give you all the power you need. Here's a demo.

While this works for data, it doesn't work for scenarios where you're trying to insert something into the layout above. For example I have a page with a layout that has a navigation bar. On the layout we have a heading, and then the content below. For each page I would like to change the heading. Our design means the heading can't be in the child page.. it's like

Heading Text
NavBar Content

So we were hoping to have the heading pushed up from the child page.

I'm reasonably new to svelte, so there could be a more "svelte-y" way to do this than the slots, happy to be educated

Edit:
Ignore me.. loading data in a +page.ts and then accessing it using the $page store works going up the chain, that's a perfectly good enough solution

@niemyjski
Copy link

@Rich-Harris Thanks for your sample and detailed explanation. I'm using a CSR app only and so I'm not so sure this approach works (page data is always null, but still debugging). Regardless coming from multiple experiences with different frameworks this doesn't feel idiomatic at all. I have a super simple app with a sidebar (drawer) and having a named sidebar slot in this case seems very intuitive.

I am finding myself really digging through samples finding solutions. It would be great if features like this one could be showcased in a common but simple starter app.

@Cybolic
Copy link

Cybolic commented Nov 4, 2023

[...]

How do you guys deal with the data or code that would normally get loaded in the +layout.server.ts? You would have to re-do all that in each+page.server.ts and pass it over to the imported Layout component right (which is clearly not an acceptable solution)?

I do it the same way as before, still in the top +layout.server.ts. The data is inherited by child layouts.

@dummdidumm
Copy link
Member

dummdidumm commented Nov 11, 2023

This will be solved through snippets in Svelte 5, if SSR is not a concern for you: https://www.sveltelab.dev/hd0z3oisqttk8tx

In SSR this likely won't work because the child sets the context after the parent has rendered.

@codepainting
Copy link

This will be solved through snippets in Svelte 5: https://www.sveltelab.dev/hd0z3oisqttk8tx

The "snippet" and the counterpart "render" seem extremely promising as highlighted in the Svelte Summit talk.

As we all know naming things is the hardest part in software engineering, so my initial reaction to the name "snippet" is that the connotation of a small code part (a snippet) may not (or may, depending on the intentions) imply the use of this framework feature for certain scenarios. If it would be intended for small code parts the name would be perfectly fine.

On the other hand if this is the "new and better slot" feature, it would be used (definitely has the potential to) for larger pieces of code that allows us to structure code e.g. within +layouts, which this issue was originally all about. In this context the name snippet might be a bit misleading especially for new users.

My first thoughts in this context would be names like "part", "piece" or "extract" which would nicely coexist with the render counterpart and make less assumptions about the size of code the feature is intended to handle.

But that's just naming — the idea seems really great and certainly tops my wish-list!

@Rich-Harris
Copy link
Member

This will be solved through snippets in Svelte 5, if SSR is not a concern for you

You know... I think we could solve this in SSR. Thinking aloud (apologies if this idea has already been presented, but this thread is too long to re-read in its entirety right now!) — perhaps if we had something like this...

src/routes/
  some-route/
    +page.svelte
    +page-footer.svelte
    +page-header.svelte
  +layout.svelte

...and the layout looked like this...

<script>
  let { data, children, header, footer } = $props();
</script>

<header>
  {#if header}
    {@render header()}
  {:else}
    <!-- default header content -->
  {/if}
</header>

<main>{@render children()}</main>

<footer>
  {#if footer}
    {@render footer()}
  {:else}
    <!-- default footer content -->
  {/if}
</footer>

...we could make it work?

@terwer
Copy link

terwer commented Nov 26, 2023

Find a solution in https://stackoverflow.com/a/71672937/4037224

@rChaoz
Copy link

rChaoz commented Feb 28, 2024

This will be solved through snippets in Svelte 5, if SSR is not a concern for you: https://www.sveltelab.dev/hd0z3oisqttk8tx

In SSR this likely won't work because the child sets the context after the parent has rendered.

That's a very nice solution. But that makes me think, why wouldn't the same thing work in Svelte 4/earlier as well?
Instead of the setting the layout state it to a @snippet in the child page (through the context setter), just set it to a component instead and render it using <svelte:component>. I think this solution is even better than passing components using $page.data and loaders.

You can even pass props using that same context!
Demo: https://www.sveltelab.dev/uqcbqnbimjsb880

Although I think this still suffers from SSR issues.

@cowboycodr
Copy link

Will snippets in Svelte 5 solve this issue?

@rChaoz
Copy link

rChaoz commented May 7, 2024

I think they should, but I'm not sure how. Without anything special needed, a layout can do something like this:

// +layout.svelte

<script>
    const slots = $state({
        header: null,
        footer: null,
    })
    setContext("layoutSlots", slots)
</script>

<div class="header">
    {@render slots.header}
</div>

<main>
    <slot/>
<main>

<div class="footer">
    {@render slots.footer}
</div>

And:

// +page.svelte

<script>
    const slots = getContext("layoutSlots")
    slots.header = header
    onDestroy(() => {
        slots.header = null
    })
</script>

{#snippet header()}
    ...
{/snippet}

This works, but I would love to see a better mechanism for this, that ensures all slots are loaded/unloaded at the same time, as they normally would in a component. One idea would be that top-level snippets in a page are passed as slots, but I don't think would be too great.

Additionally, since slots are now just snippets (plain JS values) passed as props, it would be nice to be able to easily pass any data from the page to the layout. One idea might be a designated export const value is passed as spread props to the layout. Then, this would be very easy to do:

// +page.svelte
import type { LayoutProps } from "./$types"

export const layoutProps: LayoutProps = {
    header,  // snippet
    other: 5,  // arbitrary value
}

Could even be made reactive with export const x = $state({ ...}).
Proof of concept

@Bishwas-py
Copy link

#627 (comment) Is this solution implemented on the kit code base?

@iolyd
Copy link

iolyd commented Jun 20, 2024

#627 (comment) Passing snippets from children pages/layouts to parent layouts using a context like this works well in simple cases, but it quickly becomes trickier if you want to properly handle reverting to parent snippets when unmounting nested routes/layouts.

For example, in a dashboard layout where children routes can set different sidebar / header / footer content, the context could look more like:

<!-- /(dashboard)/+layout.svelte -->
<script lang="ts">
  let header = $state<Snippet[]>([]);
  let sidebar = $state<Snippet[]>([]);
  let footer = $state<Snippet[]>([]);

  setContext('dashboard', {
    setHeader(snippet: Snippet) {
      header.push(snippet);
      return onDestroy(() => {
        header.pop();
      });
    },
    setSidebar(snippet: Snippet) {
      sidebar.push(snippet);
      return onDestroy(() => {
        sidebar.pop();
      });
    },
    setFooter(snippet: Snippet) {
      footer.push(snippet);
      return onDestroy(() => {
        footer.pop();
      });
    },
  });

  let { children }: { children: Snippet } = $props();
</script>

<div>
  {#if header.length}
    <header>
      {@render header[header.length - 1]?.()}
    </header>
  {/if}
  {#if sidebar.length}
    <nav>
      {@render sidebar[sidebar.length - 1]?.()}
    </nav>
  {/if}
  <article>
    {@render children()}
  </article>
  {#if footer.length}
    <footer>
      {@render footer[header.length - 1]?.()}
    </footer>
  {/if}
</div>

This context can then be used simply like so:

<!-- /(dashboard)/projects/+page.svelte -->
<script lang="ts">
  import { getContext } from 'svelte';

  let { data } = $props();
  const { setHeader, setSidebar } = getContext('dashboard');

  setHeader(header);
  setSidebar(sidebar);
</script>

{#snippet header()}
  <div>Some header</div>
{/snippet}

{#snippet sidebar()}
  <section>Some links</section>
{/snippet}

<h1>{data.project.title}</h1>

@cmolina
Copy link

cmolina commented Jul 23, 2024

Based on #627 (comment), I implemented a wrapper for the Context API that allows you to get & set only the last element of a property, and supports dynamic properties.

<!-- /(dashboard)/+layout.svelte -->
<script lang="ts">
  import { setStackContext } from '$lib/stackContext.svelte.js';

  const dashboard = setStackContext('dashboard', {});

  let { children }: { children: Snippet } = $props();
</script>

<div>
  <header>
    {@render dashboard.header?.()}
  </header>
  <nav>
    {@render dashboard.sidebar?.()}
  </nav>
  <article>
    {@render children()}
  </article>
  <footer>
    {@render dashboard.footer?.()}
  </footer>
</div>
<!-- /(dashboard)/projects/+page.svelte -->
<script lang="ts">
  import { getStackContext } from '$lib/stackContext.svelte.js';

  let { data } = $props();
  const dashboard = getStackContext('dashboard');
  dashboard.header = header;
  dashboard.sidebar = sidebar;
</script>

{#snippet header()}
  <div>Some header</div>
{/snippet}

{#snippet sidebar()}
  <section>Some links</section>
{/snippet}

<h1>{data.project.title}</h1>

Internally, it works by keeping an internal object with stacks, and by returning a Proxy for end user consumption.

// src/lib/stackContext.svelte.ts
import { getContext, onDestroy, setContext } from 'svelte';

type RecordStack<V> = { [P in string]: Array<V> }

export function setStackContext<V>(key: string, context: Record<string, V>) {
  const internalRecordStack = $state(createRecordStackFrom<V>(context));

  const proxy = setContext(Symbol.for(key), createPublicProxyFor<V>(internalRecordStack))

  return proxy;
}

function createRecordStackFrom<V>(context: Record<string, V>) {
  return Object.entries(context).reduce((acc, [k, v]) => {
    acc[k] = [v];
    return acc;
  }, {} as RecordStack<V>);
}

function createPublicProxyFor<V>(internalRecordStack: RecordStack<V>) {
  return new Proxy(internalRecordStack, {
    get(target, propertyKey, receiver) {
      if (typeof propertyKey === 'symbol') return Reflect.get(target, propertyKey, receiver);

      return Reflect.get(internalRecordStack, propertyKey, receiver)?.at(-1);
    },
    set(target, propertyKey, value, receiver) {
      if (typeof propertyKey === 'symbol') return Reflect.set(target, propertyKey, value, receiver);

      if (!(propertyKey in target)) {
        Reflect.set(target, propertyKey, [], receiver);
      }
      const stack = Reflect.get(target, propertyKey, receiver);
      stack.push(value);

      onDestroy(() => stack.pop());

      return true;
    },
  }) as Record<string, V>;
}

export function getStackContext<V>(key: string) {
  return getContext<ReturnType<typeof setStackContext<V>>>(Symbol.for(key))
}

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 svelte This feature can't be implemented without changes to Svelte itself
Projects
None yet
Development

No branches or pull requests