Skip to content

Commit 7961fe9

Browse files
committed
feat(router): plain head exports merging order
reverses head merging for plain objects but retains order for functions
1 parent c4752e6 commit 7961fe9

File tree

4 files changed

+182
-15
lines changed

4 files changed

+182
-15
lines changed

.changeset/some-emus-fly.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@qwik.dev/router': major
3+
---
4+
5+
Breaking: The order of head export merging has been slightly. Plain objects now override outer ones. Functions still are run inner-first.

packages/docs/src/routes/docs/(qwikrouter)/pages/index.mdx

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ The example above sets the title, as well as some [Open Graph](https://ogp.me/)
7979

8080
> 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.
8181
82-
Look into `useDocumentHead()` to read and consume the `DocumentHead` object from within your component.
82+
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.
8383

8484
### Dynamic Head
8585

@@ -121,11 +121,30 @@ export const head: DocumentHead = ({resolveValue, params}) => {
121121
};
122122
```
123123

124+
#### A note on ordering
125+
126+
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.
127+
128+
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.
129+
130+
```ts
131+
export const head: DocumentHead = ({ head }) => {
132+
return {
133+
title: `MySite - ${head.title}`,
134+
};
135+
};
136+
```
137+
138+
So first all plain object `head` exports are merged, and then the function `head` exports are called in reverse order.
139+
140+
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.
141+
Without a `key`, all entries are included.
142+
124143
### Server-injected Head
125144

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

128-
The values passed will be used as the default values for `useDocumentHead()`, before the `head` exports are resolved.
147+
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.
129148

130149
```tsx title="src/entry.ssr.tsx" {10}
131150
import { createRenderer } from "@qwik.dev/router";

packages/qwik-router/src/runtime/src/head.ts

Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import type {
1111
Editable,
1212
ResolveSyncValue,
1313
ActionInternal,
14+
ContentModuleHead,
1415
} from './types';
1516
import { isPromise } from './utils';
1617

@@ -37,28 +38,36 @@ export const resolveHead = (
3738
}
3839
return data;
3940
}) as any as ResolveSyncValue;
40-
const headProps: DocumentHeadProps = {
41-
head,
42-
withLocale: (fn) => withLocale(locale, fn),
43-
resolveValue: getData,
44-
...routeLocation,
45-
};
4641

47-
for (let i = contentModules.length - 1; i >= 0; i--) {
48-
const contentModuleHead = contentModules[i] && contentModules[i].head;
42+
const fns: Extract<ContentModuleHead, Function>[] = [];
43+
for (const contentModule of contentModules) {
44+
const contentModuleHead = contentModule?.head;
4945
if (contentModuleHead) {
5046
if (typeof contentModuleHead === 'function') {
51-
resolveDocumentHead(
52-
head,
53-
withLocale(locale, () => contentModuleHead(headProps))
54-
);
47+
// Functions are executed inner before outer
48+
fns.unshift(contentModuleHead);
5549
} else if (typeof contentModuleHead === 'object') {
50+
// Objects are merged inner over outer
5651
resolveDocumentHead(head, contentModuleHead);
5752
}
5853
}
5954
}
55+
if (fns.length) {
56+
const headProps: DocumentHeadProps = {
57+
head,
58+
withLocale: (fn) => withLocale(locale, fn),
59+
resolveValue: getData,
60+
...routeLocation,
61+
};
62+
63+
withLocale(locale, () => {
64+
for (const fn of fns) {
65+
resolveDocumentHead(head, fn(headProps));
66+
}
67+
});
68+
}
6069

61-
return headProps.head;
70+
return head;
6271
};
6372

6473
const resolveDocumentHead = (
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { resolveHead } from './head';
3+
import type { ContentModuleHead } from './types';
4+
5+
const endpoint = {} as any;
6+
const routeLocation = {} as any;
7+
const locale = 'en';
8+
const defaults = {
9+
title: 'Default Title',
10+
meta: [{ key: 'desc', name: 'description', content: 'Default description' }],
11+
link: [{ key: 'css', rel: 'stylesheet', href: 'default.css' }],
12+
};
13+
const mergeHeads = (...modules: any[]) =>
14+
resolveHead(endpoint, routeLocation, modules.map((m) => ({ head: m })) as any, locale, defaults);
15+
16+
describe('resolveHead', () => {
17+
it('should merge contentModule properties correctly', () => {
18+
const baseModule: ContentModuleHead = {
19+
title: 'Base Title',
20+
meta: [{ key: 'desc', name: 'description', content: 'Base description' }],
21+
links: [{ key: 'css', rel: 'stylesheet', href: 'base.css' }],
22+
};
23+
24+
const overrideModule: ContentModuleHead = {
25+
title: 'Override Title',
26+
meta: [{ key: 'keywords', content: 'override, test' }],
27+
links: [{ key: 'icon', rel: 'icon', href: 'favicon.ico' }],
28+
};
29+
30+
const result = mergeHeads(baseModule, overrideModule);
31+
32+
expect(result.title).toBe('Override Title');
33+
expect(result.meta).toEqual([
34+
{ key: 'desc', name: 'description', content: 'Base description' },
35+
{ key: 'keywords', content: 'override, test' },
36+
]);
37+
expect(result.links).toEqual([
38+
{ key: 'css', rel: 'stylesheet', href: 'base.css' },
39+
{ key: 'icon', rel: 'icon', href: 'favicon.ico' },
40+
]);
41+
});
42+
43+
it('should handle missing override properties', () => {
44+
const baseModule: ContentModuleHead = {
45+
title: 'Base Title',
46+
meta: [{ key: 'desc', content: 'Base description' }],
47+
};
48+
49+
const overrideModule: ContentModuleHead = {};
50+
51+
const result = mergeHeads(baseModule, overrideModule);
52+
53+
expect(result.title).toBe('Base Title');
54+
expect(result.meta).toEqual([{ key: 'desc', content: 'Base description' }]);
55+
});
56+
57+
it('should handle missing base properties', () => {
58+
const baseModule: ContentModuleHead = {};
59+
60+
const overrideModule: ContentModuleHead = {
61+
title: 'Override Title',
62+
meta: [{ key: 'keywords', content: 'override, test' }],
63+
};
64+
65+
const result = mergeHeads(baseModule, overrideModule);
66+
67+
expect(result.title).toBe('Override Title');
68+
expect(result.meta).toEqual([
69+
{ key: 'desc', name: 'description', content: 'Default description' },
70+
{ key: 'keywords', content: 'override, test' },
71+
]);
72+
});
73+
74+
it('should not mutate input objects', () => {
75+
const baseModule: ContentModuleHead = {
76+
title: 'Base Title',
77+
meta: [{ name: 'description', content: 'Base description' }],
78+
};
79+
80+
const overrideModule: ContentModuleHead = {
81+
title: 'Override Title',
82+
meta: [{ name: 'keywords', content: 'override, test' }],
83+
};
84+
85+
const baseCopy = JSON.parse(JSON.stringify(baseModule));
86+
const overrideCopy = JSON.parse(JSON.stringify(overrideModule));
87+
88+
mergeHeads(baseModule, overrideModule);
89+
90+
expect(baseModule).toEqual(baseCopy);
91+
expect(overrideModule).toEqual(overrideCopy);
92+
});
93+
});
94+
95+
describe('resolveHead with functions', () => {
96+
it('should execute head functions in correct order and merge results', () => {
97+
const baseModule: ContentModuleHead = (props) => ({
98+
title: props.head.title + ' - My Site',
99+
meta: [{ key: 'desc', name: 'description', content: 'Base description' }],
100+
});
101+
102+
const overrideModule: ContentModuleHead = (props) => ({
103+
title: 'Override Title',
104+
meta: [{ key: 'desc', name: 'description', content: 'will be overridden' }],
105+
});
106+
107+
const result = mergeHeads(baseModule, overrideModule);
108+
109+
expect(result.title).toBe('Override Title - My Site');
110+
expect(result.meta).toEqual([
111+
{ key: 'desc', name: 'description', content: 'Base description' },
112+
]);
113+
});
114+
115+
it('should handle mix of object and function heads', () => {
116+
const objectModule: ContentModuleHead = {
117+
title: 'Object Title',
118+
meta: [{ key: 'desc', name: 'description', content: 'Object description' }],
119+
};
120+
121+
const functionModule: ContentModuleHead = (props) => ({
122+
title: props.head.title + ' - My Site',
123+
meta: [{ key: 'keywords', content: 'function, test' }],
124+
});
125+
126+
const result = mergeHeads(objectModule, functionModule);
127+
128+
expect(result.title).toBe('Object Title - My Site');
129+
expect(result.meta).toEqual([
130+
{ key: 'desc', name: 'description', content: 'Object description' },
131+
{ key: 'keywords', content: 'function, test' },
132+
]);
133+
});
134+
});

0 commit comments

Comments
 (0)