Skip to content

Commit a79b155

Browse files
marvinhagemeisterbartlomiejuclaude
authored
fix: make <Head> work in client (#3252)
Closes #3310 --------- Co-authored-by: Bartek Iwańczuk <biwanczuk@gmail.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 19d3589 commit a79b155

19 files changed

Lines changed: 557 additions & 131 deletions

File tree

docs/latest/advanced/head.md

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -43,11 +43,6 @@ export default define.page((ctx) => {
4343
For more complex scenarios, or to set page metadata from
4444
[islands](/docs/concepts/islands), Fresh ships with the `<Head>`-component.
4545

46-
> [info]: The `<Head>` component is not dynamic by default. It will not
47-
> automatically update the document title or other head elements on the client
48-
> side when component state changes. The head elements are set during server
49-
> rendering or initial page load.
50-
5146
```tsx routes/about.tsx
5247
import { Head } from "fresh/runtime";
5348

@@ -64,6 +59,29 @@ export default define.page((ctx) => {
6459
});
6560
```
6661

62+
### Dynamic head updates from islands
63+
64+
The `<Head>` component works in [islands](/docs/concepts/islands) too. When
65+
component state changes, the document head is updated automatically:
66+
67+
```tsx islands/MetaUpdater.tsx
68+
import { useState } from "preact/hooks";
69+
import { Head } from "fresh/runtime";
70+
71+
export default function MetaUpdater() {
72+
const [title, setTitle] = useState("Welcome");
73+
74+
return (
75+
<div>
76+
<Head>
77+
<title>{title}</title>
78+
</Head>
79+
<button onClick={() => setTitle("Updated!")}>Change title</button>
80+
</div>
81+
);
82+
}
83+
```
84+
6785
### Avoiding duplicate tags
6886

6987
You might end up with duplicate tags, when multiple `<Head />` components are
@@ -75,13 +93,15 @@ the matching element:
7593
3. Check if an element with the same `id` attribute
7694
4. Only for `<meta>` elements: Check if there is a `<meta>` element with the
7795
same `name` attribute
78-
5. No matching element was found, Fresh will create a new one and append it to
96+
5. Only for `<link>` elements: Check if there is a `<link>` element with the
97+
same `rel` attribute
98+
6. No matching element was found, Fresh will create a new one and append it to
7999
`<head>`
80100

81101
When multiple `<Head>` components render an element with the same key, the
82-
**last one rendered wins**. Since Fresh renders top-down (app wrapper layout
83-
route page component), a route page can override defaults set in `_app.tsx` by
84-
using the same `key` prop.
102+
**last one rendered wins**. Since Fresh renders top-down (app wrapper -> layout
103+
-> route -> page component), a route page can override defaults set in
104+
`_app.tsx` by using the same `key` prop.
85105

86106
> [info]: The `<title>`-tag is automatically deduplicated, even without a `key`
87107
> prop.

packages/fresh/src/runtime/client/preact_hooks_client.ts

Lines changed: 88 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Fragment, h, options as preactOptions } from "preact";
1+
import { Fragment, h, options as preactOptions, type VNode } from "preact";
22
import {
33
assetHashingHook,
44
CLIENT_NAV_ATTR,
@@ -7,87 +7,109 @@ import {
77
} from "../shared_internal.ts";
88
import { BUILD_ID } from "@fresh/build-id";
99
import { renderToString } from "preact-render-to-string";
10-
import { useEffect } from "preact/hooks";
10+
import { useContext, useEffect } from "preact/hooks";
11+
import { HeadContext } from "../head.ts";
1112

1213
// deno-lint-ignore no-explicit-any
1314
const options: InternalPreactOptions = preactOptions as any;
1415

16+
function WrappedHead(
17+
// deno-lint-ignore no-explicit-any
18+
{ originalType, props, key }: { originalType: string; props: any; key: any },
19+
) {
20+
const enabled = useContext(HeadContext);
21+
22+
useEffect(() => {
23+
if (!enabled) return;
24+
25+
const text = renderToString(h(Fragment, null, props.children));
26+
27+
if (originalType === "title") {
28+
document.title = text;
29+
return;
30+
}
31+
32+
let matched: HTMLElement | null = null;
33+
if (key) {
34+
matched = document.head.querySelector(
35+
`head [data-key="${key}"]`,
36+
) as HTMLElement ?? null;
37+
}
38+
39+
if (matched === null && props.id) {
40+
matched = document.head.querySelector(
41+
`#${props.id}`,
42+
) as HTMLElement ??
43+
null;
44+
}
45+
46+
if (matched === null) {
47+
if (originalType === "meta") {
48+
matched = document.head.querySelector(
49+
`head [name="${props.name}"]`,
50+
) as HTMLElement ?? null;
51+
} else if (originalType === "link" && props.rel) {
52+
matched = document.head.querySelector(
53+
`head link[rel="${props.rel}"]`,
54+
) as HTMLElement ?? null;
55+
} else if (originalType === "base") {
56+
matched = document.head.querySelector(originalType) ?? null;
57+
}
58+
}
59+
60+
if (matched === null) {
61+
matched = document.createElement(originalType);
62+
}
63+
64+
if (matched.textContent !== text) {
65+
matched.textContent = text;
66+
}
67+
68+
applyProps(props, matched);
69+
}, [originalType, props, key]);
70+
71+
if (enabled) {
72+
return null;
73+
}
74+
75+
return h(originalType, { ...props, _freshPatched: true });
76+
}
77+
1578
const oldVNodeHook = options.vnode;
1679
options.vnode = (vnode) => {
1780
assetHashingHook(vnode, BUILD_ID);
1881

19-
if (typeof vnode.type === "string") {
82+
const originalType = vnode.type;
83+
if (typeof originalType === "string") {
2084
if (CLIENT_NAV_ATTR in vnode.props) {
2185
const value = vnode.props[CLIENT_NAV_ATTR];
2286
if (typeof value === "boolean") {
2387
vnode.props[CLIENT_NAV_ATTR] = String(value);
2488
}
25-
}
26-
}
27-
28-
const originalType = vnode.type;
29-
30-
if (typeof originalType === "string") {
31-
switch (originalType) {
32-
case "title":
33-
case "meta":
34-
case "link":
35-
case "script":
36-
case "style":
37-
case "base":
38-
case "noscript":
39-
case "template":
40-
// deno-lint-ignore no-constant-condition
41-
if (false) {
89+
// deno-lint-ignore no-explicit-any
90+
} else if (!(vnode.props as any)._freshPatched) {
91+
switch (originalType) {
92+
case "title":
93+
case "meta":
94+
case "link":
95+
case "script":
96+
case "style":
97+
case "base":
98+
case "noscript":
99+
case "template": {
42100
// deno-lint-ignore no-explicit-any
43-
vnode.type = (props: any) => {
44-
useEffect(() => {
45-
const text = renderToString(h(Fragment, null, props.children));
46-
47-
if (originalType === "title") {
48-
document.title = text;
49-
return;
50-
}
51-
52-
let matched: HTMLElement | null = null;
53-
if (vnode.key) {
54-
matched = document.head.querySelector(
55-
`head [data-key="${vnode.key}"]`,
56-
) as HTMLElement ?? null;
57-
}
58-
59-
if (matched === null && props.id) {
60-
matched = document.head.querySelector(
61-
`#${props.name}`,
62-
) as HTMLElement ??
63-
null;
64-
}
65-
66-
if (matched === null) {
67-
if (originalType === "meta") {
68-
matched = document.head.querySelector(
69-
`head [name="${props.name}"]`,
70-
) as HTMLElement ?? null;
71-
} else if (originalType === "base") {
72-
matched = document.head.querySelector(originalType) ?? null;
73-
}
74-
}
75-
76-
if (matched === null) {
77-
matched = document.createElement(originalType as string);
78-
}
79-
80-
if (matched.textContent !== text) {
81-
matched.textContent = text;
82-
}
83-
84-
applyProps(props, matched);
85-
}, []);
86-
87-
return null;
101+
const v = vnode as VNode<any>;
102+
const props = vnode.props;
103+
const key = vnode.key;
104+
v.type = WrappedHead;
105+
v.props = {
106+
originalType,
107+
props,
108+
key,
88109
};
110+
break;
89111
}
90-
break;
112+
}
91113
}
92114
}
93115

packages/fresh/src/runtime/server/preact_hooks.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,8 @@ options[OptionsType.DIFF] = (vnode) => {
367367
continue;
368368
} else if (originalType === "meta" && key === "content") {
369369
continue;
370+
} else if (originalType === "link" && key === "href") {
371+
continue;
370372
}
371373

372374
cacheKey += `::${props[key]}`;
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { useEffect, useState } from "preact/hooks";
2+
import { Head } from "fresh/runtime";
3+
4+
export function DynamicMetaIsland() {
5+
const [count, setCount] = useState(0);
6+
const [ready, setReady] = useState(false);
7+
8+
useEffect(() => {
9+
setReady(true);
10+
}, []);
11+
12+
return (
13+
<div class={ready ? "ready" : ""}>
14+
<Head>
15+
<meta name="foo" content={`value-${count}`} />
16+
</Head>
17+
<button type="button" onClick={() => setCount((v) => v + 1)}>
18+
update
19+
</button>
20+
</div>
21+
);
22+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { useEffect, useState } from "preact/hooks";
2+
import { Head } from "fresh/runtime";
3+
4+
export function LinkIsland() {
5+
const [ready, setReady] = useState(false);
6+
7+
useEffect(() => {
8+
setReady(true);
9+
}, []);
10+
11+
return (
12+
<div class={ready ? "ready" : ""}>
13+
<Head>
14+
<link rel="canonical" href="https://example.com/ok" />
15+
</Head>
16+
</div>
17+
);
18+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { useEffect, useState } from "preact/hooks";
2+
import { Head } from "fresh/runtime";
3+
4+
export function MultiHeadA() {
5+
const [ready, setReady] = useState(false);
6+
7+
useEffect(() => {
8+
setReady(true);
9+
}, []);
10+
11+
return (
12+
<div class={ready ? "ready-a" : ""}>
13+
<Head>
14+
<title>from island A</title>
15+
<meta name="author" content="island-a" />
16+
</Head>
17+
</div>
18+
);
19+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { useEffect, useState } from "preact/hooks";
2+
import { Head } from "fresh/runtime";
3+
4+
export function MultiHeadB() {
5+
const [ready, setReady] = useState(false);
6+
7+
useEffect(() => {
8+
setReady(true);
9+
}, []);
10+
11+
return (
12+
<div class={ready ? "ready-b" : ""}>
13+
<Head>
14+
<meta name="description" content="from-island-b" />
15+
</Head>
16+
</div>
17+
);
18+
}

packages/fresh/tests/fixture_head/routes/_app.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export default function Page({ Component }: PageProps) {
88
<title>not ok</title>
99
<meta name="foo" content="not ok" />
1010
<meta name="bar" content="not ok" />
11+
<link rel="canonical" href="https://example.com/not-ok" />
1112
<style>not ok</style>
1213
<style id="style-id">not ok</style>
1314
<template key="a">not ok</template>
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { DynamicMetaIsland } from "../islands/DynamicMetaIsland.tsx";
2+
3+
export default function Page() {
4+
return <DynamicMetaIsland />;
5+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { LinkIsland } from "../islands/LinkIsland.tsx";
2+
3+
export default function Page() {
4+
return <LinkIsland />;
5+
}

0 commit comments

Comments
 (0)