Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,8 @@ jobs:
- name: Prepare Playground
run: pnpm nuxt prepare playground

- name: Prepare E2E Fixture
run: pnpm nuxt prepare test/named-layout-slots/e2e/fixture

- name: Run Tests
run: pnpm run test
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,5 @@ node_modules
*.vsix
packages/vscode/LICENSE
packages/vscode/README.md
.idea
.nuxt
7 changes: 6 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,20 @@
"dev": "pnpm -r --parallel dev",
"prepare": "zin link vscode",
"publish": "node scripts/publish.ts",
"test": "vitest"
"test": "vitest",
"test:e2e": "pnpm run test:prepare && vitest run --project named-layout-slots-e2e",
"test:prepare": "pnpm --filter @dxup/nuxt build && nuxt prepare test/named-layout-slots/e2e/fixture"
},
"devDependencies": {
"@nuxt/test-utils": "catalog:",
"@types/node": "catalog:",
"@vue/test-utils": "catalog:",
"@zinkawaii/eslint-config": "catalog:",
"@zinkawaii/scripts": "catalog:",
"@zinkawaii/tsconfig": "catalog:",
"bumpp": "catalog:",
"eslint": "catalog:",
"happy-dom": "catalog:",
"pathe": "catalog:",
"tinyexec": "catalog:",
"tsdown": "catalog:",
Expand Down
130 changes: 97 additions & 33 deletions packages/nuxt/src/module/named-layout-slots/runtime/layouts.ts
Original file line number Diff line number Diff line change
@@ -1,57 +1,107 @@
import { type ComponentInternalInstance, computed, defineComponent, getCurrentInstance, h, inject, onScopeDispose, provide, shallowRef, type ShallowRef, type Slots } from "vue";
import { computed, defineComponent, Fragment, getCurrentInstance, h, inject, onActivated, onDeactivated, onScopeDispose, onUpdated, provide, shallowRef, type Slots } from "vue";
// @ts-expect-error virtual file
import { NuxtLayout } from "#build/dxup/layouts.mjs";
import { injectionKey } from "./registry";

interface LayoutSlotsRegistry {
slots: ShallowRef<Slots | null>;
ready: Promise<void>;
use: (slots: Slots, owner: ComponentInternalInstance | null) => void;
interface SlotsLayer {
slots: Slots;
uid: number;
}

const injectionKey = Symbol.for("dxup:layout-slots");

export default defineComponent((props, ctx) => {
const layers: SlotsLayer[] = [];
const slots = shallowRef<Slots | null>(null);
let currentOwner: ComponentInternalInstance | null = null;
let layerUid = 0;
let resolveReady: () => void;

provide<LayoutSlotsRegistry>(injectionKey, {
function update() {
// a single active page needs no merging
slots.value = layers.length > 1 ? mergeLayers([...layers]) : layers[0]?.slots ?? null;
}

provide(injectionKey, {
slots,
ready: new Promise((resolve) => {
ready: new Promise<void>((resolve) => {
resolveReady = resolve;
}),
use(value, owner) {
// a nested page must not override the slots of its top-level page
if (currentOwner) {
for (let parent = owner?.parent; parent; parent = parent.parent) {
if (parent === currentOwner) {
return;
}
use(value) {
const layer: SlotsLayer = {
slots: value,
uid: layerUid++,
};
function add() {
if (layers.every((entry) => entry.slots !== value)) {
layers.push(layer);
update();
}
}

slots.value = value;
currentOwner = owner;
onScopeDispose(() => {
// the next page can finish setup before this page is disposed
// in that case, the old page no longer owns the registry and must not clear it
if (currentOwner === owner) {
slots.value = null;
currentOwner = null;
function remove() {
const index = layers.findIndex((entry) => entry.slots === value);
if (index !== -1) {
layers.splice(index, 1);
update();
}
});
}

add();
// a page keeps providing its slots until it is unmounted,
// so a navigation only switches the slots once the next page has settled
onScopeDispose(remove);
// a page cached by <KeepAlive> must not provide slots while inactive
onDeactivated(remove);
onActivated(add);
resolveReady?.();
},
invalidate() {
// always expose a fresh object; reactivity propagates correctly only on identity change
slots.value = layers.length ? mergeLayers([...layers]) : null;
},
getOwner(name) {
for (let i = layers.length - 1; i >= 0; i--) {
if (layers[i].slots[name]) {
return layers[i].uid;
}
}
},
});

return () => h(NuxtLayout, props, ctx.slots);
});

/**
* Merges the slots of all mounted pages. The most recently mounted pages have
* a higher priority.
* @param layers
*/
function mergeLayers(layers: SlotsLayer[]): Slots {
const merged: Slots = Object.create(null);
for (let i = layers.length - 1; i >= 0; i--) {
for (const key in layers[i].slots) {
if (!(key in merged)) {
Object.defineProperty(merged, key, {
enumerable: true,
configurable: true,
// resolve lazily, so re-rendered pages provide their current slot functions
get: () => {
for (let j = layers.length - 1; j >= 0; j--) {
const slot = layers[j].slots[key];
if (slot) {
return slot;
}
}
},
});
}
}
}
return merged;
}

/**
* Returns the slots forwarded by the active page via named layout slots.
*/
export function useLayoutSlots() {
const registry = inject<LayoutSlotsRegistry | null>(injectionKey, null);
const registry = inject(injectionKey, null);
return computed(() => registry?.slots.value ?? {});
}

Expand All @@ -63,7 +113,7 @@ export const LayoutSlot = defineComponent({
},
},
setup(props, ctx) {
const registry = inject<LayoutSlotsRegistry>(injectionKey);
const registry = inject(injectionKey);
const currentInstance = getCurrentInstance();

const render = () => {
Expand All @@ -72,7 +122,10 @@ export const LayoutSlot = defineComponent({
// the parent layout should be able to render the raw slots as fallback
registry?.slots.value?.[props.name] ?? currentInstance?.parent?.slots[props.name];
// an unprovided slot falls back to the children of the original `<slot>`
return slot?.(ctx.attrs) ?? ctx.slots.default?.();
const children = slot?.(ctx.attrs) ?? ctx.slots.default?.();
// key the content by the forward that provides it, so it lives and dies
// with its page instead of being patched across page boundaries
return h(Fragment, { key: registry?.getOwner(props.name) ?? -1 }, children);
};

if (import.meta.server && registry && !registry.slots.value?.[props.name]) {
Expand All @@ -83,10 +136,21 @@ export const LayoutSlot = defineComponent({
});

export const LayoutSlotsForward = defineComponent((props, ctx) => {
const registry = inject<LayoutSlotsRegistry>(injectionKey);
const currentInstance = getCurrentInstance();
const registry = inject(injectionKey);

registry?.use(ctx.slots, currentInstance);
registry?.use(ctx.slots);

// invalidate the registry when conditional slots are re-rendered (`ctx.slots` is mutated in place)
if (import.meta.client && registry) {
let keys = Object.keys(ctx.slots).join("\0");
onUpdated(() => {
const next = Object.keys(ctx.slots).join("\0");
if (next !== keys) {
keys = next;
registry.invalidate();
}
});
}

return () => {
const vnodes = ctx.slots.default?.();
Expand Down
11 changes: 11 additions & 0 deletions packages/nuxt/src/module/named-layout-slots/runtime/registry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type { InjectionKey, ShallowRef, Slots } from "vue";

export interface LayoutSlotsRegistry {
slots: ShallowRef<Slots | null>;
ready: Promise<void>;
use: (slots: Slots) => void;
invalidate: () => void;
getOwner: (name: string) => number | undefined;
}

export const injectionKey = Symbol.for("dxup:layout-slots") as InjectionKey<LayoutSlotsRegistry>;
Loading