diff --git a/docs/latest/advanced/head.md b/docs/latest/advanced/head.md
index aa4ab667e88..bb6d9a15df2 100644
--- a/docs/latest/advanced/head.md
+++ b/docs/latest/advanced/head.md
@@ -43,11 +43,6 @@ export default define.page((ctx) => {
For more complex scenarios, or to set page metadata from
[islands](/docs/concepts/islands), Fresh ships with the `
`-component.
-> [info]: The `` component is not dynamic by default. It will not
-> automatically update the document title or other head elements on the client
-> side when component state changes. The head elements are set during server
-> rendering or initial page load.
-
```tsx routes/about.tsx
import { Head } from "fresh/runtime";
@@ -64,6 +59,29 @@ export default define.page((ctx) => {
});
```
+### Dynamic head updates from islands
+
+The `` component works in [islands](/docs/concepts/islands) too. When
+component state changes, the document head is updated automatically:
+
+```tsx islands/MetaUpdater.tsx
+import { useState } from "preact/hooks";
+import { Head } from "fresh/runtime";
+
+export default function MetaUpdater() {
+ const [title, setTitle] = useState("Welcome");
+
+ return (
+
+
+ {title}
+
+
+
+ );
+}
+```
+
### Avoiding duplicate tags
You might end up with duplicate tags, when multiple `
` components are
@@ -75,13 +93,15 @@ the matching element:
3. Check if an element with the same `id` attribute
4. Only for `` elements: Check if there is a `` element with the
same `name` attribute
-5. No matching element was found, Fresh will create a new one and append it to
+5. Only for `` elements: Check if there is a `` element with the
+ same `rel` attribute
+6. No matching element was found, Fresh will create a new one and append it to
`
`
When multiple `
` components render an element with the same key, the
-**last one rendered wins**. Since Fresh renders top-down (app wrapper → layout →
-route → page component), a route page can override defaults set in `_app.tsx` by
-using the same `key` prop.
+**last one rendered wins**. Since Fresh renders top-down (app wrapper -> layout
+-> route -> page component), a route page can override defaults set in
+`_app.tsx` by using the same `key` prop.
> [info]: The `
`-tag is automatically deduplicated, even without a `key`
> prop.
diff --git a/packages/fresh/src/runtime/client/preact_hooks_client.ts b/packages/fresh/src/runtime/client/preact_hooks_client.ts
index f8d114c9194..3c9c12b5bb1 100644
--- a/packages/fresh/src/runtime/client/preact_hooks_client.ts
+++ b/packages/fresh/src/runtime/client/preact_hooks_client.ts
@@ -1,4 +1,4 @@
-import { Fragment, h, options as preactOptions } from "preact";
+import { Fragment, h, options as preactOptions, type VNode } from "preact";
import {
assetHashingHook,
CLIENT_NAV_ATTR,
@@ -7,87 +7,109 @@ import {
} from "../shared_internal.ts";
import { BUILD_ID } from "@fresh/build-id";
import { renderToString } from "preact-render-to-string";
-import { useEffect } from "preact/hooks";
+import { useContext, useEffect } from "preact/hooks";
+import { HeadContext } from "../head.ts";
// deno-lint-ignore no-explicit-any
const options: InternalPreactOptions = preactOptions as any;
+function WrappedHead(
+ // deno-lint-ignore no-explicit-any
+ { originalType, props, key }: { originalType: string; props: any; key: any },
+) {
+ const enabled = useContext(HeadContext);
+
+ useEffect(() => {
+ if (!enabled) return;
+
+ const text = renderToString(h(Fragment, null, props.children));
+
+ if (originalType === "title") {
+ document.title = text;
+ return;
+ }
+
+ let matched: HTMLElement | null = null;
+ if (key) {
+ matched = document.head.querySelector(
+ `head [data-key="${key}"]`,
+ ) as HTMLElement ?? null;
+ }
+
+ if (matched === null && props.id) {
+ matched = document.head.querySelector(
+ `#${props.id}`,
+ ) as HTMLElement ??
+ null;
+ }
+
+ if (matched === null) {
+ if (originalType === "meta") {
+ matched = document.head.querySelector(
+ `head [name="${props.name}"]`,
+ ) as HTMLElement ?? null;
+ } else if (originalType === "link" && props.rel) {
+ matched = document.head.querySelector(
+ `head link[rel="${props.rel}"]`,
+ ) as HTMLElement ?? null;
+ } else if (originalType === "base") {
+ matched = document.head.querySelector(originalType) ?? null;
+ }
+ }
+
+ if (matched === null) {
+ matched = document.createElement(originalType);
+ }
+
+ if (matched.textContent !== text) {
+ matched.textContent = text;
+ }
+
+ applyProps(props, matched);
+ }, [originalType, props, key]);
+
+ if (enabled) {
+ return null;
+ }
+
+ return h(originalType, { ...props, _freshPatched: true });
+}
+
const oldVNodeHook = options.vnode;
options.vnode = (vnode) => {
assetHashingHook(vnode, BUILD_ID);
- if (typeof vnode.type === "string") {
+ const originalType = vnode.type;
+ if (typeof originalType === "string") {
if (CLIENT_NAV_ATTR in vnode.props) {
const value = vnode.props[CLIENT_NAV_ATTR];
if (typeof value === "boolean") {
vnode.props[CLIENT_NAV_ATTR] = String(value);
}
- }
- }
-
- const originalType = vnode.type;
-
- if (typeof originalType === "string") {
- switch (originalType) {
- case "title":
- case "meta":
- case "link":
- case "script":
- case "style":
- case "base":
- case "noscript":
- case "template":
- // deno-lint-ignore no-constant-condition
- if (false) {
+ // deno-lint-ignore no-explicit-any
+ } else if (!(vnode.props as any)._freshPatched) {
+ switch (originalType) {
+ case "title":
+ case "meta":
+ case "link":
+ case "script":
+ case "style":
+ case "base":
+ case "noscript":
+ case "template": {
// deno-lint-ignore no-explicit-any
- vnode.type = (props: any) => {
- useEffect(() => {
- const text = renderToString(h(Fragment, null, props.children));
-
- if (originalType === "title") {
- document.title = text;
- return;
- }
-
- let matched: HTMLElement | null = null;
- if (vnode.key) {
- matched = document.head.querySelector(
- `head [data-key="${vnode.key}"]`,
- ) as HTMLElement ?? null;
- }
-
- if (matched === null && props.id) {
- matched = document.head.querySelector(
- `#${props.name}`,
- ) as HTMLElement ??
- null;
- }
-
- if (matched === null) {
- if (originalType === "meta") {
- matched = document.head.querySelector(
- `head [name="${props.name}"]`,
- ) as HTMLElement ?? null;
- } else if (originalType === "base") {
- matched = document.head.querySelector(originalType) ?? null;
- }
- }
-
- if (matched === null) {
- matched = document.createElement(originalType as string);
- }
-
- if (matched.textContent !== text) {
- matched.textContent = text;
- }
-
- applyProps(props, matched);
- }, []);
-
- return null;
+ const v = vnode as VNode;
+ const props = vnode.props;
+ const key = vnode.key;
+ v.type = WrappedHead;
+ v.props = {
+ originalType,
+ props,
+ key,
};
+ break;
}
- break;
+ }
}
}
diff --git a/packages/fresh/src/runtime/server/preact_hooks.ts b/packages/fresh/src/runtime/server/preact_hooks.ts
index 320439bb97e..aa39971b961 100644
--- a/packages/fresh/src/runtime/server/preact_hooks.ts
+++ b/packages/fresh/src/runtime/server/preact_hooks.ts
@@ -337,6 +337,8 @@ options[OptionsType.DIFF] = (vnode) => {
continue;
} else if (originalType === "meta" && key === "content") {
continue;
+ } else if (originalType === "link" && key === "href") {
+ continue;
}
cacheKey += `::${props[key]}`;
diff --git a/packages/fresh/tests/fixture_head/islands/DynamicMetaIsland.tsx b/packages/fresh/tests/fixture_head/islands/DynamicMetaIsland.tsx
new file mode 100644
index 00000000000..56ad31609ca
--- /dev/null
+++ b/packages/fresh/tests/fixture_head/islands/DynamicMetaIsland.tsx
@@ -0,0 +1,22 @@
+import { useEffect, useState } from "preact/hooks";
+import { Head } from "fresh/runtime";
+
+export function DynamicMetaIsland() {
+ const [count, setCount] = useState(0);
+ const [ready, setReady] = useState(false);
+
+ useEffect(() => {
+ setReady(true);
+ }, []);
+
+ return (
+
+
+
+
+
+
+ );
+}
diff --git a/packages/fresh/tests/fixture_head/islands/LinkIsland.tsx b/packages/fresh/tests/fixture_head/islands/LinkIsland.tsx
new file mode 100644
index 00000000000..090447c2af0
--- /dev/null
+++ b/packages/fresh/tests/fixture_head/islands/LinkIsland.tsx
@@ -0,0 +1,18 @@
+import { useEffect, useState } from "preact/hooks";
+import { Head } from "fresh/runtime";
+
+export function LinkIsland() {
+ const [ready, setReady] = useState(false);
+
+ useEffect(() => {
+ setReady(true);
+ }, []);
+
+ return (
+
+
+
+
+
+ );
+}
diff --git a/packages/fresh/tests/fixture_head/islands/MultiHeadA.tsx b/packages/fresh/tests/fixture_head/islands/MultiHeadA.tsx
new file mode 100644
index 00000000000..133f006bc1c
--- /dev/null
+++ b/packages/fresh/tests/fixture_head/islands/MultiHeadA.tsx
@@ -0,0 +1,19 @@
+import { useEffect, useState } from "preact/hooks";
+import { Head } from "fresh/runtime";
+
+export function MultiHeadA() {
+ const [ready, setReady] = useState(false);
+
+ useEffect(() => {
+ setReady(true);
+ }, []);
+
+ return (
+
+
+ from island A
+
+
+
+ );
+}
diff --git a/packages/fresh/tests/fixture_head/islands/MultiHeadB.tsx b/packages/fresh/tests/fixture_head/islands/MultiHeadB.tsx
new file mode 100644
index 00000000000..2cac3373e4e
--- /dev/null
+++ b/packages/fresh/tests/fixture_head/islands/MultiHeadB.tsx
@@ -0,0 +1,18 @@
+import { useEffect, useState } from "preact/hooks";
+import { Head } from "fresh/runtime";
+
+export function MultiHeadB() {
+ const [ready, setReady] = useState(false);
+
+ useEffect(() => {
+ setReady(true);
+ }, []);
+
+ return (
+
+
+
+
+
+ );
+}
diff --git a/packages/fresh/tests/fixture_head/routes/_app.tsx b/packages/fresh/tests/fixture_head/routes/_app.tsx
index 4379c39cdcf..6099cdee418 100644
--- a/packages/fresh/tests/fixture_head/routes/_app.tsx
+++ b/packages/fresh/tests/fixture_head/routes/_app.tsx
@@ -8,6 +8,7 @@ export default function Page({ Component }: PageProps) {
not ok
+
not ok
diff --git a/packages/fresh/tests/fixture_head/routes/dynamic_meta.tsx b/packages/fresh/tests/fixture_head/routes/dynamic_meta.tsx
new file mode 100644
index 00000000000..5c026e49b84
--- /dev/null
+++ b/packages/fresh/tests/fixture_head/routes/dynamic_meta.tsx
@@ -0,0 +1,5 @@
+import { DynamicMetaIsland } from "../islands/DynamicMetaIsland.tsx";
+
+export default function Page() {
+ return ;
+}
diff --git a/packages/fresh/tests/fixture_head/routes/link.tsx b/packages/fresh/tests/fixture_head/routes/link.tsx
new file mode 100644
index 00000000000..dde56f62a1a
--- /dev/null
+++ b/packages/fresh/tests/fixture_head/routes/link.tsx
@@ -0,0 +1,5 @@
+import { LinkIsland } from "../islands/LinkIsland.tsx";
+
+export default function Page() {
+ return ;
+}
diff --git a/packages/fresh/tests/fixture_head/routes/multi.tsx b/packages/fresh/tests/fixture_head/routes/multi.tsx
new file mode 100644
index 00000000000..870771b3f38
--- /dev/null
+++ b/packages/fresh/tests/fixture_head/routes/multi.tsx
@@ -0,0 +1,11 @@
+import { MultiHeadA } from "../islands/MultiHeadA.tsx";
+import { MultiHeadB } from "../islands/MultiHeadB.tsx";
+
+export default function Page() {
+ return (
+ <>
+
+
+ >
+ );
+}
diff --git a/packages/fresh/tests/head_test.tsx b/packages/fresh/tests/head_test.tsx
index b43356e893d..b19d30d5c74 100644
--- a/packages/fresh/tests/head_test.tsx
+++ b/packages/fresh/tests/head_test.tsx
@@ -4,12 +4,17 @@ import {
buildProd,
parseHtml,
waitFor,
+ waitForText,
withBrowserApp,
} from "./test_utils.tsx";
import { expect } from "@std/expect";
import { FakeServer } from "../src/test_utils.ts";
import * as path from "@std/path";
+const applyHeadCache = await buildProd({
+ root: path.join(import.meta.dirname!, "fixture_head"),
+});
+
Deno.test("Head - ssr - updates title", async () => {
const handler = new App()
.appWrapper(({ Component }) => {
@@ -151,19 +156,45 @@ Deno.test("Head - ssr - merge keyed", async () => {
expect(last?.textContent).toEqual("ok");
});
+Deno.test("Head - ssr - updates link", async () => {
+ const handler = new App()
+ .appWrapper(({ Component }) => {
+ return (
+
+
+
+
+
+