Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/some-emus-fly.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@qwik.dev/router': major
---

Breaking: The order of head export merging has been slightly. Plain objects now override outer ones. Functions still are run inner-first.
23 changes: 21 additions & 2 deletions packages/docs/src/routes/docs/(qwikrouter)/pages/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ The example above sets the title, as well as some [Open Graph](https://ogp.me/)

> HTML places the `<head>` tag as the first element within `<html>` (at the very top of the HTML content). The `<head>` section is not something that your route component renders directly because it would break the HTML streaming.

Look into `useDocumentHead()` to read and consume the `DocumentHead` object from within your component.
Look into `useDocumentHead()` to read and consume the `DocumentHead` object from within your component; you can also use `<DocumentHeadTags />` to render the tags directly and correctly.

### Dynamic Head

Expand Down Expand Up @@ -121,11 +121,30 @@ export const head: DocumentHead = ({resolveValue, params}) => {
};
```

#### A note on ordering

The `head` exports are merged in an outward-in manner. This means that values from `index.tsx` will override the layout's `head` export, and that in turn will override the root layout's `head` export.

However, for dynamic `head()` exports (functions), the ordering is reversed. This allows to always add something to the title, for example, in a layout component.

```ts
export const head: DocumentHead = ({ head }) => {
return {
title: `MySite - ${head.title}`,
};
};
```

So first all plain object `head` exports are merged, and then the function `head` exports are called in reverse order.

Furthermore, if two values in arrays (like `meta` or `links`) have the same `key`, the last one wins. This allows you to override specific meta tags.
Without a `key`, all entries are included.

### Server-injected Head

You can also pass `documentHead` to `createRenderer()` as part of the `serverData` option.

The values passed will be used as the default values for `useDocumentHead()`, before the `head` exports are resolved.
The values passed will be used as the default values for `useDocumentHead()`, before the `head` exports are resolved. So layouts and pages can override the values set here.

```tsx title="src/entry.ssr.tsx" {10}
import { createRenderer } from "@qwik.dev/router";
Expand Down
35 changes: 22 additions & 13 deletions packages/qwik-router/src/runtime/src/head.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type {
Editable,
ResolveSyncValue,
ActionInternal,
ContentModuleHead,
} from './types';
import { isPromise } from './utils';

Expand All @@ -37,28 +38,36 @@ export const resolveHead = (
}
return data;
}) as any as ResolveSyncValue;
const headProps: DocumentHeadProps = {
head,
withLocale: (fn) => withLocale(locale, fn),
resolveValue: getData,
...routeLocation,
};

for (let i = contentModules.length - 1; i >= 0; i--) {
const contentModuleHead = contentModules[i] && contentModules[i].head;
const fns: Extract<ContentModuleHead, Function>[] = [];
for (const contentModule of contentModules) {
const contentModuleHead = contentModule?.head;
if (contentModuleHead) {
if (typeof contentModuleHead === 'function') {
resolveDocumentHead(
head,
withLocale(locale, () => contentModuleHead(headProps))
);
// Functions are executed inner before outer
fns.unshift(contentModuleHead);
} else if (typeof contentModuleHead === 'object') {
// Objects are merged inner over outer
resolveDocumentHead(head, contentModuleHead);
}
}
}
if (fns.length) {
const headProps: DocumentHeadProps = {
head,
withLocale: (fn) => withLocale(locale, fn),
resolveValue: getData,
...routeLocation,
};

withLocale(locale, () => {
for (const fn of fns) {
resolveDocumentHead(head, fn(headProps));
}
});
}

return headProps.head;
return head;
};

const resolveDocumentHead = (
Expand Down
134 changes: 134 additions & 0 deletions packages/qwik-router/src/runtime/src/head.unit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { describe, it, expect } from 'vitest';
import { resolveHead } from './head';
import type { ContentModuleHead } from './types';

const endpoint = {} as any;
const routeLocation = {} as any;
const locale = 'en';
const defaults = {
title: 'Default Title',
meta: [{ key: 'desc', name: 'description', content: 'Default description' }],
link: [{ key: 'css', rel: 'stylesheet', href: 'default.css' }],
};
const mergeHeads = (...modules: any[]) =>
resolveHead(endpoint, routeLocation, modules.map((m) => ({ head: m })) as any, locale, defaults);

describe('resolveHead', () => {
it('should merge contentModule properties correctly', () => {
const baseModule: ContentModuleHead = {
title: 'Base Title',
meta: [{ key: 'desc', name: 'description', content: 'Base description' }],
links: [{ key: 'css', rel: 'stylesheet', href: 'base.css' }],
};

const overrideModule: ContentModuleHead = {
title: 'Override Title',
meta: [{ key: 'keywords', content: 'override, test' }],
links: [{ key: 'icon', rel: 'icon', href: 'favicon.ico' }],
};

const result = mergeHeads(baseModule, overrideModule);

expect(result.title).toBe('Override Title');
expect(result.meta).toEqual([
{ key: 'desc', name: 'description', content: 'Base description' },
{ key: 'keywords', content: 'override, test' },
]);
expect(result.links).toEqual([
{ key: 'css', rel: 'stylesheet', href: 'base.css' },
{ key: 'icon', rel: 'icon', href: 'favicon.ico' },
]);
});

it('should handle missing override properties', () => {
const baseModule: ContentModuleHead = {
title: 'Base Title',
meta: [{ key: 'desc', content: 'Base description' }],
};

const overrideModule: ContentModuleHead = {};

const result = mergeHeads(baseModule, overrideModule);

expect(result.title).toBe('Base Title');
expect(result.meta).toEqual([{ key: 'desc', content: 'Base description' }]);
});

it('should handle missing base properties', () => {
const baseModule: ContentModuleHead = {};

const overrideModule: ContentModuleHead = {
title: 'Override Title',
meta: [{ key: 'keywords', content: 'override, test' }],
};

const result = mergeHeads(baseModule, overrideModule);

expect(result.title).toBe('Override Title');
expect(result.meta).toEqual([
{ key: 'desc', name: 'description', content: 'Default description' },
{ key: 'keywords', content: 'override, test' },
]);
});

it('should not mutate input objects', () => {
const baseModule: ContentModuleHead = {
title: 'Base Title',
meta: [{ name: 'description', content: 'Base description' }],
};

const overrideModule: ContentModuleHead = {
title: 'Override Title',
meta: [{ name: 'keywords', content: 'override, test' }],
};

const baseCopy = JSON.parse(JSON.stringify(baseModule));
const overrideCopy = JSON.parse(JSON.stringify(overrideModule));

mergeHeads(baseModule, overrideModule);

expect(baseModule).toEqual(baseCopy);
expect(overrideModule).toEqual(overrideCopy);
});
});

describe('resolveHead with functions', () => {
it('should execute head functions in correct order and merge results', () => {
const baseModule: ContentModuleHead = (props) => ({
title: props.head.title + ' - My Site',
meta: [{ key: 'desc', name: 'description', content: 'Base description' }],
});

const overrideModule: ContentModuleHead = (props) => ({
title: 'Override Title',
meta: [{ key: 'desc', name: 'description', content: 'will be overridden' }],
});

const result = mergeHeads(baseModule, overrideModule);

expect(result.title).toBe('Override Title - My Site');
expect(result.meta).toEqual([
{ key: 'desc', name: 'description', content: 'Base description' },
]);
});

it('should handle mix of object and function heads', () => {
const objectModule: ContentModuleHead = {
title: 'Object Title',
meta: [{ key: 'desc', name: 'description', content: 'Object description' }],
};

const functionModule: ContentModuleHead = (props) => ({
title: props.head.title + ' - My Site',
meta: [{ key: 'keywords', content: 'function, test' }],
});

const result = mergeHeads(objectModule, functionModule);

expect(result.title).toBe('Object Title - My Site');
expect(result.meta).toEqual([
{ key: 'desc', name: 'description', content: 'Object description' },
{ key: 'keywords', content: 'function, test' },
]);
});
});