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<any>; + 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 ( + <div class={ready ? "ready" : ""}> + <Head> + <meta name="foo" content={`value-${count}`} /> + </Head> + <button type="button" onClick={() => setCount((v) => v + 1)}> + update + </button> + </div> + ); +} 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 ( + <div class={ready ? "ready" : ""}> + <Head> + <link rel="canonical" href="https://example.com/ok" /> + </Head> + </div> + ); +} 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 ( + <div class={ready ? "ready-a" : ""}> + <Head> + <title>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 + 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 ( + + + + + + + + + + ); + }) + .get("/", (ctx) => { + return ctx.render( + + + , + ); + }).handler(); + + const server = new FakeServer(handler); + const res = await server.get("/"); + const doc = parseHtml(await res.text()); + + const link = doc.querySelector("link[rel='canonical']") as HTMLLinkElement; + expect(link.href).toEqual("https://example.com/ok"); +}); + Deno.test({ - ignore: true, // Temporarily until client perf is fixed name: "Head - client - set title", fn: async () => { - const applyCache = await buildProd({ - root: path.join(import.meta.dirname!, "fixture_head"), - }); - const app = new App({}) .use(staticFiles()) .fsRoutes(); - applyCache(app); + applyHeadCache(app); await withBrowserApp(app, async (page, address) => { await page.goto(`${address}/title`); @@ -171,10 +202,7 @@ Deno.test({ await page.locator(".ready").wait(); await page.locator("button").click(); - await waitFor(async () => { - const title = await page.evaluate(() => document.title); - return title === "Count: 1"; - }); + await waitForText(page, "title", "Count: 1"); }); }, sanitizeOps: false, @@ -182,18 +210,13 @@ Deno.test({ }); Deno.test({ - ignore: true, // Temporarily until client perf is fixed name: "Head - client - match meta", fn: async () => { - const applyCache = await buildProd({ - root: path.join(import.meta.dirname!, "fixture_head"), - }); - const app = new App({}) .use(staticFiles()) .fsRoutes(); - applyCache(app); + applyHeadCache(app); await withBrowserApp(app, async (page, address) => { await page.goto(`${address}/meta`); @@ -210,15 +233,11 @@ Deno.test({ ) => ({ name: el.name, content: el.content })); }); - try { - expect(metas).toEqual([ - { name: "foo", content: "ok" }, - { name: "bar", content: "not ok" }, - ]); - return true; - } catch { - return false; - } + expect(metas).toEqual([ + { name: "foo", content: "ok" }, + { name: "bar", content: "not ok" }, + ]); + return true; }); }); }, @@ -227,18 +246,13 @@ Deno.test({ }); Deno.test({ - ignore: true, // Temporarily until client perf is fixed name: "Head - client - match style by id", fn: async () => { - const applyCache = await buildProd({ - root: path.join(import.meta.dirname!, "fixture_head"), - }); - const app = new App({}) .use(staticFiles()) .fsRoutes(); - applyCache(app); + applyHeadCache(app); await withBrowserApp(app, async (page, address) => { await page.goto(`${address}/id`); @@ -255,15 +269,11 @@ Deno.test({ ) => ({ id: el.id, text: el.textContent })); }); - try { - expect(styles).toEqual([ - { id: "", text: "not ok" }, - { id: "style-id", text: "ok" }, - ]); - return true; - } catch { - return false; - } + expect(styles).toEqual([ + { id: "", text: "not ok" }, + { id: "style-id", text: "ok" }, + ]); + return true; }); }); }, @@ -272,18 +282,13 @@ Deno.test({ }); Deno.test({ - ignore: true, // Temporarily until client perf is fixed name: "Head - client - match key", fn: async () => { - const applyCache = await buildProd({ - root: path.join(import.meta.dirname!, "fixture_head"), - }); - const app = new App({}) .use(staticFiles()) .fsRoutes(); - applyCache(app); + applyHeadCache(app); await withBrowserApp(app, async (page, address) => { await page.goto(`${address}/key`); @@ -303,19 +308,156 @@ Deno.test({ })); }); - try { - expect(tpls).toEqual([ - { key: "a", text: "ok" }, - { key: "b", text: "not ok" }, - { key: null, text: "not ok" }, - ]); - return true; - } catch { - return false; - } + expect(tpls).toEqual([ + { key: "a", text: "ok" }, + { key: "b", text: "not ok" }, + { key: null, text: "not ok" }, + ]); + return true; + }); + }); + }, + sanitizeOps: false, + sanitizeResources: false, +}); + +Deno.test({ + name: "Head - client - dynamic meta update", + fn: async () => { + const app = new App({}) + .use(staticFiles()) + .fsRoutes(); + + applyHeadCache(app); + + await withBrowserApp(app, async (page, address) => { + await page.goto(`${address}/dynamic_meta`); + await page.locator(".ready").wait(); + + // Verify initial meta value + await waitFor(async () => { + const content = await page.evaluate(() => { + const el = document.querySelector( + "meta[name='foo']", + ) as HTMLMetaElement; + return el?.content; + }); + expect(content).toEqual("value-0"); + return true; + }); + + // Click to update and verify meta changes reactively + await page.locator("button").click(); + + await waitFor(async () => { + const content = await page.evaluate(() => { + const el = document.querySelector( + "meta[name='foo']", + ) as HTMLMetaElement; + return el?.content; + }); + expect(content).toEqual("value-1"); + return true; + }); + }); + }, + sanitizeOps: false, + sanitizeResources: false, +}); + +Deno.test({ + name: "Head - client - link element", + fn: async () => { + const app = new App({}) + .use(staticFiles()) + .fsRoutes(); + + applyHeadCache(app); + + await withBrowserApp(app, async (page, address) => { + await page.goto(`${address}/link`); + await page.locator(".ready").wait(); + + await waitFor(async () => { + const href = await page.evaluate(() => { + const el = document.querySelector( + "link[rel='canonical']", + ) as HTMLLinkElement; + return el?.href; + }); + expect(href).toEqual("https://example.com/ok"); + return true; }); }); }, sanitizeOps: false, sanitizeResources: false, }); + +Deno.test({ + name: "Head - client - multiple islands with Head", + fn: async () => { + const app = new App({}) + .use(staticFiles()) + .fsRoutes(); + + applyHeadCache(app); + + await withBrowserApp(app, async (page, address) => { + await page.goto(`${address}/multi`); + await page.locator(".ready-a").wait(); + await page.locator(".ready-b").wait(); + + await waitFor(async () => { + const title = await page.evaluate(() => document.title); + expect(title).toEqual("from island A"); + return true; + }); + + await waitFor(async () => { + const metas = await page.evaluate(() => { + return { + author: (document.querySelector( + "meta[name='author']", + ) as HTMLMetaElement)?.content, + description: (document.querySelector( + "meta[name='description']", + ) as HTMLMetaElement)?.content, + }; + }); + expect(metas.author).toEqual("island-a"); + expect(metas.description).toEqual("from-island-b"); + return true; + }); + }); + }, + sanitizeOps: false, + sanitizeResources: false, +}); + +Deno.test({ + name: "Head - client - title updates multiple times", + fn: async () => { + const app = new App({}) + .use(staticFiles()) + .fsRoutes(); + + applyHeadCache(app); + + await withBrowserApp(app, async (page, address) => { + await page.goto(`${address}/title`); + await page.locator(".ready").wait(); + + await page.locator("button").click(); + await waitForText(page, "title", "Count: 1"); + + await page.locator("button").click(); + await waitForText(page, "title", "Count: 2"); + + await page.locator("button").click(); + await waitForText(page, "title", "Count: 3"); + }); + }, + sanitizeOps: false, + sanitizeResources: false, +}); diff --git a/packages/plugin-vite/demo/islands/tests/HeadCounter.tsx b/packages/plugin-vite/demo/islands/tests/HeadCounter.tsx new file mode 100644 index 00000000000..23502437517 --- /dev/null +++ b/packages/plugin-vite/demo/islands/tests/HeadCounter.tsx @@ -0,0 +1,23 @@ +import { useEffect, useState } from "preact/hooks"; +import { Head } from "fresh/runtime"; + +export function HeadCounter() { + const [ready, setReady] = useState(false); + const [v, set] = useState(0); + + useEffect(() => { + setReady(true); + }, []); + + return ( +
+ + Count: {v} + +

Count: {v}

+ +
+ ); +} diff --git a/packages/plugin-vite/demo/islands/tests/HeadMeta.tsx b/packages/plugin-vite/demo/islands/tests/HeadMeta.tsx new file mode 100644 index 00000000000..290c91b459c --- /dev/null +++ b/packages/plugin-vite/demo/islands/tests/HeadMeta.tsx @@ -0,0 +1,20 @@ +import { useEffect, useState } from "preact/hooks"; +import { Head } from "fresh/runtime"; + +export function HeadMeta() { + const [ready, setReady] = useState(false); + + useEffect(() => { + setReady(true); + }, []); + + return ( +
+ + + + +

check meta

+
+ ); +} diff --git a/packages/plugin-vite/demo/routes/_app.tsx b/packages/plugin-vite/demo/routes/_app.tsx new file mode 100644 index 00000000000..8e2fce9f295 --- /dev/null +++ b/packages/plugin-vite/demo/routes/_app.tsx @@ -0,0 +1,16 @@ +import type { PageProps } from "fresh"; + +export default function App({ Component }: PageProps) { + return ( + + + + + + + + + + + ); +} diff --git a/packages/plugin-vite/demo/routes/tests/head_counter.tsx b/packages/plugin-vite/demo/routes/tests/head_counter.tsx new file mode 100644 index 00000000000..aeee2413b00 --- /dev/null +++ b/packages/plugin-vite/demo/routes/tests/head_counter.tsx @@ -0,0 +1,5 @@ +import { HeadCounter } from "../../islands/tests/HeadCounter.tsx"; + +export default function Page() { + return ; +} diff --git a/packages/plugin-vite/demo/routes/tests/head_meta.tsx b/packages/plugin-vite/demo/routes/tests/head_meta.tsx new file mode 100644 index 00000000000..0f3cb1f671b --- /dev/null +++ b/packages/plugin-vite/demo/routes/tests/head_meta.tsx @@ -0,0 +1,5 @@ +import { HeadMeta } from "../../islands/tests/HeadMeta.tsx"; + +export default function Page() { + return ; +} diff --git a/packages/plugin-vite/tests/build_test.ts b/packages/plugin-vite/tests/build_test.ts index 73c650fbc1e..46e7948667c 100644 --- a/packages/plugin-vite/tests/build_test.ts +++ b/packages/plugin-vite/tests/build_test.ts @@ -636,3 +636,31 @@ Deno.test({ sanitizeOps: false, sanitizeResources: false, }); + +Deno.test({ + name: "vite build - client side ", + fn: async () => { + await launchProd( + { cwd: viteResult.tmp }, + async (address) => { + await withBrowser(async (page) => { + await page.goto(`${address}/tests/head_counter`, { + waitUntil: "networkidle2", + }); + + await page.locator(".ready").wait(); + await page.locator("button").click(); + await waitForText(page, ".result", "Count: 1"); + + await waitFor(async () => { + const title = await page.evaluate(() => document.title); + expect(title).toEqual("Count: 1"); + return true; + }); + }); + }, + ); + }, + sanitizeOps: false, + sanitizeResources: false, +}); diff --git a/packages/plugin-vite/tests/dev_server_test.ts b/packages/plugin-vite/tests/dev_server_test.ts index e55e4f73fe9..16f989fd6d7 100644 --- a/packages/plugin-vite/tests/dev_server_test.ts +++ b/packages/plugin-vite/tests/dev_server_test.ts @@ -546,3 +546,47 @@ Deno.test({ sanitizeOps: false, sanitizeResources: false, }); + +Deno.test({ + name: "vite dev - client side ", + fn: async () => { + await withBrowser(async (page) => { + await page.goto(`${demoServer.address()}/tests/head_counter`, { + waitUntil: "networkidle2", + }); + + await page.locator(".ready").wait(); + await page.locator("button").click(); + await waitForText(page, ".result", "Count: 1"); + + await waitFor(async () => { + const title = await page.evaluate(() => document.title); + expect(title).toEqual("Count: 1"); + return true; + }); + + await page.goto(`${demoServer.address()}/tests/head_meta`, { + waitUntil: "networkidle2", + }); + + await page.locator(".ready").wait(); + + await waitFor(async () => { + const custom = await page + .locator("meta[name='custom']") + // deno-lint-ignore no-explicit-any + .evaluate((el: any) => el.content); + expect(custom).toEqual("ok"); + + const custom2 = await page + .locator("meta[name='custom-new']") + // deno-lint-ignore no-explicit-any + .evaluate((el: any) => el.content); + expect(custom2).toEqual("ok"); + return true; + }); + }); + }, + sanitizeOps: false, + sanitizeResources: false, +});