From edcc822bcbc6f347450dd29ba6cfccee78f59f45 Mon Sep 17 00:00:00 2001 From: Brooks Lybrand Date: Wed, 24 Jul 2024 13:58:29 -0500 Subject: [PATCH] Add a/b split --- app/modules/search/ab-session.server.ts | 46 ++++++ app/modules/search/docsearch.tsx | 37 +++++ app/modules/search/index.tsx | 149 +++++--------------- app/modules/search/orama.tsx | 143 +++++++++++++++++++ app/root.tsx | 6 +- app/styles/{orama-search.css => search.css} | 0 vite.config.ts | 3 + 7 files changed, 272 insertions(+), 112 deletions(-) create mode 100644 app/modules/search/ab-session.server.ts create mode 100644 app/modules/search/docsearch.tsx create mode 100644 app/modules/search/orama.tsx rename app/styles/{orama-search.css => search.css} (100%) diff --git a/app/modules/search/ab-session.server.ts b/app/modules/search/ab-session.server.ts new file mode 100644 index 00000000..787c6516 --- /dev/null +++ b/app/modules/search/ab-session.server.ts @@ -0,0 +1,46 @@ +import { createCookieSessionStorage } from "@remix-run/node"; + +export let unencryptedSession = createCookieSessionStorage({ + cookie: { + name: "ab_session", + path: "/", + sameSite: "lax", + expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7), // 1 week + }, +}); + +const SESSION_KEY = "ab-docsearch-bucket"; + +export async function bucketUser(request: Request) { + let session = await unencryptedSession.getSession( + request.headers.get("Cookie") + ); + + let { searchParams } = new URL(request.url); + let bucket = searchParams.get("bucket"); + + // if the bucket isn't being overridden by a query parameter, use the session + if (!isBucketValue(bucket)) { + bucket = session.get(SESSION_KEY); + } + + // if no bucket in the session, assign the user + if (!isBucketValue(bucket)) { + bucket = Math.random() > 0.5 ? "orama" : "docsearch"; + } + + let safeBucket = isBucketValue(bucket) ? bucket : "docsearch"; + + session.set(SESSION_KEY, safeBucket); + + return { + bucket: safeBucket, + headers: { + "Set-Cookie": await unencryptedSession.commitSession(session), + }, + }; +} + +function isBucketValue(bucket: any): bucket is "docsearch" | "orama" { + return bucket === "docsearch" || bucket === "orama"; +} diff --git a/app/modules/search/docsearch.tsx b/app/modules/search/docsearch.tsx new file mode 100644 index 00000000..10606cc3 --- /dev/null +++ b/app/modules/search/docsearch.tsx @@ -0,0 +1,37 @@ +import type { DocSearchProps } from "@docsearch/react"; +import { useHydrated } from "~/ui/utils"; +import { Suspense, lazy } from "react"; + +const OriginalDocSearch = lazy(() => + import("@docsearch/react").then((module) => ({ + default: module.DocSearch, + })) +); + +let docSearchProps = { + appId: "RB6LOUCOL0", + indexName: "reactrouter", + apiKey: "b50c5d7d9f4610c9785fa945fdc97476", +} satisfies DocSearchProps; + +// TODO: Refactor a bit when we add Vite with css imports per component +// This will allow us to have two versions of the component, one that has +// the button with display: none, and the other with button styles +export function DocSearch() { + let hydrated = useHydrated(); + + if (!hydrated) { + // The Algolia doc search container is hard-coded at 40px. It doesn't + // render anything on the server, so we get a mis-match after hydration. + // This placeholder prevents layout shift when the search appears. + return
; + } + + return ( + }> +
+ +
+
+ ); +} diff --git a/app/modules/search/index.tsx b/app/modules/search/index.tsx index f1a57553..a9680b7d 100644 --- a/app/modules/search/index.tsx +++ b/app/modules/search/index.tsx @@ -1,19 +1,25 @@ -import { Suspense, createContext, lazy, useContext, useState } from "react"; -import { useHydrated, useLayoutEffect } from "~/ui/utils"; +import { Suspense, lazy } from "react"; +import { type loader as rootLoader } from "~/root"; +import { useHydrated } from "~/ui/utils"; +import { useRouteLoaderData } from "@remix-run/react"; -import "@orama/searchbox/dist/index.css"; import "@docsearch/css/dist/style.css"; -import "~/styles/orama-search.css"; -import { useColorScheme } from "~/modules/color-scheme/components"; +import "~/styles/search.css"; -const OriginalSearchBox = lazy(() => - import("@orama/searchbox").then((module) => ({ - default: module.SearchBox, +const DocSearchButton = lazy(() => + import("./docsearch").then((module) => ({ + default: module.DocSearch, })) ); - -const SearchModalContext = createContext void)>( - null +const OramaSearchButton = lazy(() => + import("./orama").then((module) => ({ + default: module.SearchButton, + })) +); +const OramaSearch = lazy(() => + import("./orama").then((module) => ({ + default: module.SearchModalProvider, + })) ); export function SearchModalProvider({ @@ -21,124 +27,45 @@ export function SearchModalProvider({ }: { children: React.ReactNode; }) { - let [showSearchModal, setShowSearchModal] = useState(false); - const isHydrated = useHydrated(); - const colorScheme = useSearchModalColorScheme(); + let bucket = useBucket(); - return ( - + if (bucket === "orama") { + return ( - {isHydrated ? ( - setShowSearchModal(false)} - colorScheme={colorScheme} - theme="secondary" - themeConfig={{ - light: { - "--background-color-fourth": "#f7f7f7", - }, - dark: { - "--background-color-fourth": "#383838", - }, - }} - resultsMap={{ - description: "content", - }} - facetProperty="section" - backdrop - /> - ) : null} + {children} - {children} - - ); -} - -function useSetShowSearchModal() { - let context = useContext(SearchModalContext); - if (!context) { - throw new Error("useSearchModal must be used within a SearchModalProvider"); + ); } - return context; + + return <>{children}; } export function SearchButton() { let hydrated = useHydrated(); - let setShowSearchModal = useSetShowSearchModal(); + let bucket = useBucket(); if (!hydrated) { + // The Algolia doc search container is hard-coded at 40px. It doesn't + // render anything on the server, so we get a mis-match after hydration. + // This placeholder prevents layout shift when the search appears. return
; } return ( - <> + }>
- + {bucket === "orama" ? : }
- +
); } -// TODO: integrate this with ColorSchemeScript so we're not setting multiple listeners on the same media query -function useSearchModalColorScheme() { - let colorScheme = useColorScheme(); - let [systemColorScheme, setSystemColorScheme] = useState< - null | "light" | "dark" - >(null); - useLayoutEffect(() => { - if (colorScheme !== "system") { - setSystemColorScheme(null); - return; - } - let media = window.matchMedia("(prefers-color-scheme: dark)"); - let handleMedia = () => - setSystemColorScheme(media.matches ? "dark" : "light"); - handleMedia(); - media.addEventListener("change", handleMedia); - return () => { - media.removeEventListener("change", handleMedia); - }; - }, [colorScheme]); - if (colorScheme !== "system") { - return colorScheme; - } - if (systemColorScheme) { - return systemColorScheme; +function useBucket() { + const data = useRouteLoaderData("root"); + + if (!data) { + throw new Error("useBucket must be used within root route loader"); } - return "dark"; + + return data.bucket; } diff --git a/app/modules/search/orama.tsx b/app/modules/search/orama.tsx new file mode 100644 index 00000000..f811847c --- /dev/null +++ b/app/modules/search/orama.tsx @@ -0,0 +1,143 @@ +import { Suspense, createContext, lazy, useContext, useState } from "react"; +import { useHydrated, useLayoutEffect } from "~/ui/utils"; +import { useColorScheme } from "~/modules/color-scheme/components"; + +import "@orama/searchbox/dist/index.css"; + +const OramaSearch = lazy(() => + import("@orama/searchbox").then((module) => ({ + default: module.SearchBox, + })) +); + +const SearchModalContext = createContext void)>( + null +); + +export function SearchModalProvider({ + children, +}: { + children: React.ReactNode; +}) { + let [showSearchModal, setShowSearchModal] = useState(false); + const isHydrated = useHydrated(); + const colorScheme = useSearchModalColorScheme(); + + return ( + + + {isHydrated ? ( + setShowSearchModal(false)} + colorScheme={colorScheme} + theme="secondary" + themeConfig={{ + light: { + "--background-color-fourth": "#f7f7f7", + }, + dark: { + "--background-color-fourth": "#383838", + }, + }} + resultsMap={{ + description: "content", + }} + facetProperty="section" + backdrop + /> + ) : null} + + {children} + + ); +} + +function useSetShowSearchModal() { + let context = useContext(SearchModalContext); + if (!context) { + throw new Error("useSearchModal must be used within a SearchModalProvider"); + } + return context; +} + +export function SearchButton() { + let hydrated = useHydrated(); + let setShowSearchModal = useSetShowSearchModal(); + + if (!hydrated) { + return
; + } + + // TODO: replace styles + return ( + <> +
+ +
+ + ); +} + +// TODO: integrate this with ColorSchemeScript so we're not setting multiple listeners on the same media query +function useSearchModalColorScheme() { + let colorScheme = useColorScheme(); + let [systemColorScheme, setSystemColorScheme] = useState< + null | "light" | "dark" + >(null); + useLayoutEffect(() => { + if (colorScheme !== "system") { + setSystemColorScheme(null); + return; + } + let media = window.matchMedia("(prefers-color-scheme: dark)"); + let handleMedia = () => + setSystemColorScheme(media.matches ? "dark" : "light"); + handleMedia(); + media.addEventListener("change", handleMedia); + return () => { + media.removeEventListener("change", handleMedia); + }; + }, [colorScheme]); + if (colorScheme !== "system") { + return colorScheme; + } + if (systemColorScheme) { + return systemColorScheme; + } + return "dark"; +} diff --git a/app/root.tsx b/app/root.tsx index 43c319ca..555242d8 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -22,6 +22,7 @@ import { import { isHost } from "./modules/http-utils/is-host"; import iconsHref from "~/icons.svg"; import stylesheet from "~/styles/tailwind.css?url"; +import { bucketUser } from "./modules/search/ab-session.server"; export const links: LinksFunction = () => [ { rel: "stylesheet", href: stylesheet }, @@ -49,12 +50,15 @@ export let loader = async ({ request }: LoaderFunctionArgs) => { let colorScheme = await parseColorScheme(request); let isProductionHost = isHost("reactrouter.com", request); + let { bucket, headers } = await bucketUser(request); + return json( - { colorScheme, isProductionHost }, + { colorScheme, isProductionHost, bucket }, { headers: { "Cache-Control": CACHE_CONTROL.doc, Vary: "Cookie", + ...headers, }, } ); diff --git a/app/styles/orama-search.css b/app/styles/search.css similarity index 100% rename from app/styles/orama-search.css rename to app/styles/search.css diff --git a/vite.config.ts b/vite.config.ts index 419bdd16..1188fff0 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -6,6 +6,9 @@ import tsconfigPaths from "vite-tsconfig-paths"; installGlobals(); export default defineConfig({ + ssr: { + noExternal: ["@docsearch/react"], + }, server: { port: 3000, },