Skip to content

Commit 4a80aad

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 4a80aad

File tree

3 files changed

+161
-13
lines changed

3 files changed

+161
-13
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/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)