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,
},