Skip to content

Commit

Permalink
Support Fog of War form discovery and fix link prefetching (#9665)
Browse files Browse the repository at this point in the history
  • Loading branch information
brophdawg11 authored Jun 27, 2024
1 parent 1789c0c commit bd4f873
Show file tree
Hide file tree
Showing 13 changed files with 305 additions and 50 deletions.
5 changes: 5 additions & 0 deletions .changeset/shiny-dolphins-mate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@remix-run/react": patch
---

Fog of War: Support route discovery from `<Form>` components
112 changes: 111 additions & 1 deletion integration/fog-of-war-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ function getFiles() {
"app/routes/a.tsx": js`
import { Link, Outlet, useLoaderData } from "@remix-run/react";
export function loader({ request }) {
export function loader() {
return { message: "A LOADER" };
}
Expand Down Expand Up @@ -367,6 +367,116 @@ test.describe("Fog of War", () => {
).toEqual(["root", "routes/_index", "routes/a", "routes/a.b"]);
});

test("prefetches initially rendered forms", async ({ page }) => {
let fixture = await createFixture({
config: {
future: {
unstable_fogOfWar: true,
},
},
files: {
...getFiles(),
"app/root.tsx": js`
import * as React from "react";
import { Form, Links, Meta, Outlet, Scripts } from "@remix-run/react";
export default function Root() {
let [showLink, setShowLink] = React.useState(false);
return (
<html lang="en">
<head>
<Meta />
<Links />
</head>
<body>
<Form method="post" action="/a">
<button type="submit">Submit</button>
</Form>
<Outlet />
<Scripts />
</body>
</html>
);
}
`,
"app/routes/a.tsx": js`
import { useActionData } from "@remix-run/react";
export function action() {
return { message: "A ACTION" };
}
export default function Index() {
let actionData = useActionData();
return <h1 id="a">A: {actionData.message}</h1>
}
`,
},
});
let appFixture = await createAppFixture(fixture);
let app = new PlaywrightFixture(appFixture, page);

await app.goto("/", true);
await page.waitForFunction(
() => (window as any).__remixManifest.routes["routes/a"]
);
expect(
await page.evaluate(() =>
Object.keys((window as any).__remixManifest.routes)
)
).toEqual(["root", "routes/_index", "routes/a"]);

await app.clickSubmitButton("/a");
await page.waitForSelector("#a");
expect(await app.getHtml("#a")).toBe(`<h1 id="a">A: A ACTION</h1>`);
});

test("prefetches forms rendered via navigations", async ({ page }) => {
let fixture = await createFixture({
config: {
future: {
unstable_fogOfWar: true,
},
},
files: {
...getFiles(),
"app/routes/a.tsx": js`
import { Form } from "@remix-run/react";
export default function Component() {
return (
<Form method="post" action="/a/b">
<button type="submit">Submit</button>
</Form>
);
}
`,
},
});
let appFixture = await createAppFixture(fixture);
let app = new PlaywrightFixture(appFixture, page);

await app.goto("/", true);
expect(
await page.evaluate(() =>
Object.keys((window as any).__remixManifest.routes)
)
).toEqual(["root", "routes/_index", "routes/a"]);

await app.clickLink("/a");
await page.waitForSelector("form");

await page.waitForFunction(
() => (window as any).__remixManifest.routes["routes/a.b"]
);

expect(
await page.evaluate(() =>
Object.keys((window as any).__remixManifest.routes)
)
).toEqual(["root", "routes/_index", "routes/a", "routes/a.b"]);
});

test("prefetches root index child when SSR-ing a deep route", async ({
page,
}) => {
Expand Down
2 changes: 1 addition & 1 deletion integration/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"@remix-run/dev": "workspace:*",
"@remix-run/express": "workspace:*",
"@remix-run/node": "workspace:*",
"@remix-run/router": "1.17.0",
"@remix-run/router": "0.0.0-experimental-078ef8de9",
"@remix-run/server-runtime": "workspace:*",
"@types/express": "^4.17.9",
"@vanilla-extract/css": "^1.10.0",
Expand Down
100 changes: 100 additions & 0 deletions integration/prefetch-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -714,6 +714,106 @@ test.describe("single fetch", () => {
});
});

test.describe("prefetch=render (fog of war)", () => {
let appFixture: AppFixture;

test.afterAll(() => {
appFixture?.close();
});

test("adds prefetch tags after discovery", async ({ page }) => {
let fixture = await createFixture({
config: {
future: {
unstable_fogOfWar: true,
unstable_singleFetch: true,
},
},
files: {
"app/root.tsx": js`
import * as React from "react";
import {
Link,
Links,
Meta,
Outlet,
Scripts,
useLoaderData,
} from "@remix-run/react";
export default function Root() {
let [discover, setDiscover] = React.useState(false);
return (
<html lang="en">
<head>
<Meta />
<Links />
</head>
<body>
<nav id="nav">
<Link
to="/with-loader"
discover={discover ? "render" : "none"}
prefetch="render">
Loader Page
</Link>
<br/>
<button onClick={() => setDiscover(true)}>
Discover Link
</button>
</nav>
<Outlet />
<Scripts />
</body>
</html>
);
}
`,

"app/routes/_index.tsx": js`
export default function() {
return <h2 className="index">Index</h2>;
}
`,

"app/routes/with-loader.tsx": js`
export function loader() {
return { message: 'data from the loader' };
}
export default function() {
return <h2 className="with-loader">With Loader</h2>;
}
`,
},
});
appFixture = await createAppFixture(fixture);
let app = new PlaywrightFixture(appFixture, page);

let consoleLogs: string[] = [];
page.on("console", (msg) => {
consoleLogs.push(msg.text());
});

let selectors = {
data: "#nav link[href='/with-loader.data']",
route: "#nav link[href^='/build/routes/with-loader-']",
};
await app.goto("/", true);
expect(await app.page.$(selectors.data)).toBeNull();
expect(await app.page.$(selectors.route)).toBeNull();
expect(consoleLogs).toEqual([
"Tried to prefetch /with-loader but no routes matched.",
]);

await app.clickElement("button");
await page.waitForSelector(selectors.data, { state: "attached" });
await page.waitForSelector(selectors.route, { state: "attached" });

// Ensure no other links in the #nav element
expect(await page.locator("#nav link").count()).toBe(2);
});
});

test.describe("prefetch=intent (hover)", () => {
let fixture: Fixture;
let appFixture: AppFixture;
Expand Down
2 changes: 1 addition & 1 deletion packages/remix-dev/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
"@mdx-js/mdx": "^2.3.0",
"@npmcli/package-json": "^4.0.1",
"@remix-run/node": "workspace:*",
"@remix-run/router": "1.17.0",
"@remix-run/router": "0.0.0-experimental-078ef8de9",
"@remix-run/server-runtime": "workspace:*",
"@types/mdx": "^2.0.5",
"@vanilla-extract/integration": "^6.2.0",
Expand Down
5 changes: 3 additions & 2 deletions packages/remix-react/__tests__/exports-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,9 @@ let nonReExportedKeys = new Set([
// type safety, plus Link/NavLink have wrappers to support prefetching
let modifiedExports = new Set([
"Await", // types
"Link", // remix-specific prefetching loigc
"NavLink", // remix-specific prefetching loigc
"Form", // remix-specific discovery logic
"Link", // remix-specific discovery/prefetching logic
"NavLink", // remix-specific discovery/prefetching logic
"ScrollRestoration", // remix-specific SSR restoration logic
"defer", // types
"json", // types
Expand Down
29 changes: 29 additions & 0 deletions packages/remix-react/components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,13 @@ import type {
} from "@remix-run/router";
import type {
FetcherWithComponents,
FormProps,
LinkProps,
NavLinkProps,
} from "react-router-dom";
import {
Await as AwaitRR,
Form as RouterForm,
Link as RouterLink,
NavLink as RouterNavLink,
UNSAFE_DataRouterContext as DataRouterContext,
Expand Down Expand Up @@ -271,6 +273,33 @@ let Link = React.forwardRef<HTMLAnchorElement, RemixLinkProps>(
Link.displayName = "Link";
export { Link };

export interface RemixFormProps extends FormProps {
discover?: DiscoverBehavior;
}

/**
* This component renders a form tag and is the primary way the user will
* submit information via your website.
*
* @see https://remix.run/components/form
*/
let Form = React.forwardRef<HTMLFormElement, RemixFormProps>(
({ discover = "render", ...props }, forwardedRef) => {
let isAbsolute =
typeof props.action === "string" && ABSOLUTE_URL_REGEX.test(props.action);
return (
<RouterForm
{...props}
data-discover={
!isAbsolute && discover === "render" ? "true" : undefined
}
/>
);
}
);
Form.displayName = "Form";
export { Form };

export function composeEventHandlers<
EventType extends React.SyntheticEvent | Event
>(
Expand Down
34 changes: 22 additions & 12 deletions packages/remix-react/fog-of-war.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,11 @@ export function useFogOFWarDiscovery(
}

// Register a link href for patching
function registerPath(path: string | null) {
function registerElement(el: Element) {
let path =
el.tagName === "FORM"
? el.getAttribute("action")
: el.getAttribute("href");
if (!path) {
return;
}
Expand Down Expand Up @@ -164,35 +168,41 @@ export function useFogOFWarDiscovery(
}
}

// Register and fetch patches for all initially-rendered links
// Register and fetch patches for all initially-rendered links/forms
document.body
.querySelectorAll("a[data-discover]")
.forEach((a) => registerPath(a.getAttribute("href")));
.querySelectorAll("a[data-discover], form[data-discover]")
.forEach((el) => registerElement(el));

fetchPatches();

// Setup a MutationObserver to fetch all subsequently rendered links
// Setup a MutationObserver to fetch all subsequently rendered links/forms
let debouncedFetchPatches = debounce(fetchPatches, 100);

function isElement(node: Node): node is Element {
return node.nodeType === Node.ELEMENT_NODE;
}

let observer = new MutationObserver((records) => {
let links = new Set<Element>();
let elements = new Set<Element>();
records.forEach((r) => {
[r.target, ...r.addedNodes].forEach((node) => {
if (!isElement(node)) return;
if (node.tagName === "A" && node.getAttribute("data-discover")) {
links.add(node);
} else if (node.tagName !== "A") {
elements.add(node);
} else if (
node.tagName === "FORM" &&
node.getAttribute("data-discover")
) {
elements.add(node);
}
if (node.tagName !== "A") {
node
.querySelectorAll("a[data-discover]")
.forEach((el) => links.add(el));
.querySelectorAll("a[data-discover], form[data-discover]")
.forEach((el) => elements.add(el));
}
});
});
links.forEach((link) => registerPath(link.getAttribute("href")));
elements.forEach((el) => registerElement(el));

debouncedFetchPatches();
});
Expand All @@ -201,7 +211,7 @@ export function useFogOFWarDiscovery(
subtree: true,
childList: true,
attributes: true,
attributeFilter: ["data-discover", "href"],
attributeFilter: ["data-discover", "href", "action"],
});

return () => observer.disconnect();
Expand Down
Loading

0 comments on commit bd4f873

Please sign in to comment.