diff --git a/fixtures/webstudio-remix-vercel/proxy-emulator/dedupe-meta.ts b/fixtures/webstudio-remix-vercel/proxy-emulator/dedupe-meta.ts index 20257b027f61..8360cc40aa93 100644 --- a/fixtures/webstudio-remix-vercel/proxy-emulator/dedupe-meta.ts +++ b/fixtures/webstudio-remix-vercel/proxy-emulator/dedupe-meta.ts @@ -36,6 +36,7 @@ export const dedupeMeta: Plugin = { const metasSet = new Set(); let hasTitle = false; + let hasCanonicalLink = false; const rewriter = new HTMLRewriter() .on("meta", { @@ -73,6 +74,16 @@ export const dedupeMeta: Plugin = { hasTitle = true; }, + }) + .on('link[rel="canonical"]', { + element(element) { + if (hasCanonicalLink) { + element.remove(); + return; + } + + hasCanonicalLink = true; + }, }); rewriter // @ts-ignore diff --git a/packages/cli/templates/defaults/app/route-templates/html.tsx b/packages/cli/templates/defaults/app/route-templates/html.tsx index 1609162d8ecb..e79b74a6d9ff 100644 --- a/packages/cli/templates/defaults/app/route-templates/html.tsx +++ b/packages/cli/templates/defaults/app/route-templates/html.tsx @@ -20,6 +20,7 @@ import { ReactSdkContext, PageSettingsMeta, PageSettingsTitle, + PageSettingsCanonicalLink, } from "@webstudio-is/react-sdk/runtime"; import { Page, @@ -288,6 +289,7 @@ const Outlet = () => { imageLoader={imageLoader} /> {pageMeta.title} + ); }; diff --git a/packages/react-sdk/src/page-settings-canonical-link.tsx b/packages/react-sdk/src/page-settings-canonical-link.tsx new file mode 100644 index 000000000000..9db82b94faad --- /dev/null +++ b/packages/react-sdk/src/page-settings-canonical-link.tsx @@ -0,0 +1,57 @@ +import { useEffect, useState } from "react"; +import { isElementRenderedWithReact } from "./page-settings-meta"; + +type PageSettingsCanonicalLinkProps = { + href: string; +}; + +const isServer = typeof window === "undefined"; + +/** + * Link canonical tag are deduplicated on the server using the HTMLRewriter interface. + * This is not full deduplication. We simply skip rendering Page Setting link + * if it has already been rendered using HeadSlot/HeadLink. + * To prevent React on the client from re-adding the removed link tag, we skip rendering them client-side. + * This approach works because React retains server-rendered link tag as long as they are not re-rendered by the client. + * + * The following component behavior ensures this: + * 1. On the server: Render link tag as usual. + * 2. On the client: Before rendering, remove any link tag with the same `name` or `property` that were not rendered by Client React, + * and then proceed with rendering as usual. + */ +export const PageSettingsCanonicalLink = ( + props: PageSettingsCanonicalLinkProps +) => { + const [localProps, setLocalProps] = useState< + PageSettingsCanonicalLinkProps | undefined + >(); + + useEffect(() => { + const selector = `head > link[rel="canonical"]`; + let allLinks = document.querySelectorAll(selector); + + for (const meta of allLinks) { + if (!isElementRenderedWithReact(meta)) { + meta.remove(); + } + } + + allLinks = document.querySelectorAll(selector); + + if (allLinks.length === 0) { + setLocalProps(props); + } + }, [props]); + + if (isServer) { + return ; + } + + if (localProps === undefined) { + // This method also works during hydration because React retains server-rendered tags + // as long as they are not re-rendered by the client. + return; + } + + return ; +}; diff --git a/packages/react-sdk/src/runtime.ts b/packages/react-sdk/src/runtime.ts index c5b06fe65ad2..bc0785e2b641 100644 --- a/packages/react-sdk/src/runtime.ts +++ b/packages/react-sdk/src/runtime.ts @@ -3,6 +3,7 @@ export * from "./hook"; export * from "./variable-state"; export { PageSettingsMeta } from "./page-settings-meta"; export { PageSettingsTitle } from "./page-settings-title"; +export { PageSettingsCanonicalLink } from "./page-settings-canonical-link"; /** * React has issues rendering certain elements, such as errors when a element has children.