diff --git a/.changeset/light-chairs-tickle.md b/.changeset/light-chairs-tickle.md new file mode 100644 index 00000000000..1b37a18834a --- /dev/null +++ b/.changeset/light-chairs-tickle.md @@ -0,0 +1,5 @@ +--- +"@remix-run/server-runtime": patch +--- + +add handleResourceRequest implementation based on @remix-run/router behind experimental build flag diff --git a/.github/workflows/reusable-test.yml b/.github/workflows/reusable-test.yml index 8c18242b3c8..38cb0e4063b 100644 --- a/.github/workflows/reusable-test.yml +++ b/.github/workflows/reusable-test.yml @@ -9,10 +9,14 @@ on: # but we want to pass an array (node_version: "[14, 16, 18]"), # so we'll need to manually stringify it for now type: string + enable_remix_router: + required: false + type: string env: CI: true CYPRESS_INSTALL_BINARY: 0 + ENABLE_REMIX_ROUTER: ${{ inputs.enable_remix_router }} jobs: build: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 14905fe4d14..7f278706f98 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -23,3 +23,10 @@ jobs: uses: ./.github/workflows/reusable-test.yml with: node_version: '["latest"]' + + test-experimental: + if: github.repository == 'remix-run/remix' + uses: ./.github/workflows/reusable-test.yml + with: + node_version: '["latest"]' + enable_remix_router: "1" diff --git a/integration/helpers/create-fixture.ts b/integration/helpers/create-fixture.ts index 68f7e7bc19d..2ebcd80db1b 100644 --- a/integration/helpers/create-fixture.ts +++ b/integration/helpers/create-fixture.ts @@ -6,6 +6,7 @@ import getPort from "get-port"; import stripIndent from "strip-indent"; import { sync as spawnSync } from "cross-spawn"; import type { JsonObject } from "type-fest"; +import type { ServerMode } from "@remix-run/server-runtime/mode"; import type { ServerBuild } from "../../build/node_modules/@remix-run/server-runtime"; import { createRequestHandler } from "../../build/node_modules/@remix-run/server-runtime"; @@ -39,7 +40,10 @@ export async function createFixture(init: FixtureInit) { let requestDocument = async (href: string, init?: RequestInit) => { let url = new URL(href, "test://test"); - let request = new Request(url.toString(), init); + let request = new Request(url.toString(), { + ...init, + signal: new AbortController().signal, + }); return handler(request); }; @@ -84,7 +88,7 @@ export async function createFixture(init: FixtureInit) { }; } -export async function createAppFixture(fixture: Fixture) { +export async function createAppFixture(fixture: Fixture, mode?: ServerMode) { let startAppServer = async (): Promise<{ port: number; stop: () => Promise; @@ -93,9 +97,13 @@ export async function createAppFixture(fixture: Fixture) { let port = await getPort(); let app = express(); app.use(express.static(path.join(fixture.projectDir, "public"))); + app.all( "*", - createExpressHandler({ build: fixture.build, mode: "production" }) + createExpressHandler({ + build: fixture.build, + mode: mode || "production", + }) ); let server = app.listen(port); diff --git a/integration/resource-routes-test.ts b/integration/resource-routes-test.ts index 6ec3ddcd188..b99235cff9f 100644 --- a/integration/resource-routes-test.ts +++ b/integration/resource-routes-test.ts @@ -1,4 +1,5 @@ import { test, expect } from "@playwright/test"; +import { ServerMode } from "@remix-run/server-runtime/mode"; import { createAppFixture, createFixture, js } from "./helpers/create-fixture"; import type { AppFixture, Fixture } from "./helpers/create-fixture"; @@ -67,9 +68,34 @@ test.describe("loader in an app", async () => { ); } `, + "app/routes/throw-error.jsx": js` + export let loader = () => { + throw new Error('Oh noes!') + } + `, + "app/routes/return-response.jsx": js` + export let loader = () => { + return new Response('Partial', { status: 207 }); + } + `, + "app/routes/throw-response.jsx": js` + export let loader = () => { + throw new Response('Partial', { status: 207 }); + } + `, + "app/routes/return-object.jsx": js` + export let loader = () => { + return { hello: 'world' }; + } + `, + "app/routes/throw-object.jsx": js` + export let loader = () => { + throw { but: 'why' }; + } + `, }, }); - appFixture = await createAppFixture(fixture); + appFixture = await createAppFixture(fixture, ServerMode.Test); }); test.afterAll(async () => { @@ -113,5 +139,54 @@ test.describe("loader in an app", async () => { ); expect(data).toBe(SVG_CONTENTS); }); + + test("should handle errors thrown from resource routes", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + let res = await app.goto("/throw-error"); + expect(res.status()).toBe(500); + expect(await res.text()).toEqual( + "Unexpected Server Error\n\nError: Oh noes!" + ); + }); } + + test("should handle responses returned from resource routes", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + let res = await app.goto("/return-response"); + expect(res.status()).toBe(207); + expect(await res.text()).toEqual("Partial"); + }); + + test("should handle responses thrown from resource routes", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + let res = await app.goto("/throw-response"); + expect(res.status()).toBe(207); + expect(await res.text()).toEqual("Partial"); + }); + + test("should handle objects returned from resource routes", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + let res = await app.goto("/return-object"); + expect(res.status()).toBe(200); + expect(await res.json()).toEqual({ hello: "world" }); + }); + + test("should handle objects thrown from resource routes", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + let res = await app.goto("/throw-object"); + expect(res.status()).toBe(500); + expect(await res.text()).toEqual( + "Unexpected Server Error\n\n[object Object]" + ); + }); }); diff --git a/integration/tsconfig.json b/integration/tsconfig.json index 350d804d800..b8fd6c97ac7 100644 --- a/integration/tsconfig.json +++ b/integration/tsconfig.json @@ -2,7 +2,7 @@ "include": ["**/*.ts"], "exclude": ["helpers/*-template"], "compilerOptions": { - "lib": ["ES2019"], + "lib": ["ES2019", "DOM", "DOM.Iterable"], "target": "ES2019", "module": "CommonJS", "skipLibCheck": true, diff --git a/package.json b/package.json index 4f9dc41ae63..051cd502dd1 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "@rollup/plugin-babel": "^5.2.2", "@rollup/plugin-json": "^4.1.0", "@rollup/plugin-node-resolve": "^11.0.1", + "@rollup/plugin-replace": "^4.0.0", "@testing-library/cypress": "^8.0.2", "@testing-library/jest-dom": "^5.16.2", "@testing-library/react": "^13.3.0", diff --git a/packages/create-remix/tsconfig.json b/packages/create-remix/tsconfig.json index b3c3ddf07cd..c5fe36ae1b2 100644 --- a/packages/create-remix/tsconfig.json +++ b/packages/create-remix/tsconfig.json @@ -2,7 +2,7 @@ "include": ["**/*.ts"], "exclude": ["dist", "**/node_modules/**"], "compilerOptions": { - "lib": ["ES2019"], + "lib": ["ES2019", "DOM", "DOM.Iterable"], "target": "ES2019", "module": "CommonJS", "skipLibCheck": true, diff --git a/packages/remix-server-runtime/__tests__/data-test.ts b/packages/remix-server-runtime/__tests__/data-test.ts index a5187d23f22..0f0cc8e6c46 100644 --- a/packages/remix-server-runtime/__tests__/data-test.ts +++ b/packages/remix-server-runtime/__tests__/data-test.ts @@ -169,7 +169,9 @@ describe("loaders", () => { try { possibleError = await callRouteLoader({ request, - match, + loader: match.route.module.loader, + routeId: match.route.id, + params: match.params, loadContext: {}, }); } catch (error) { @@ -206,7 +208,9 @@ describe("actions", () => { try { possibleError = await callRouteAction({ request, - match, + action: match.route.module.action, + routeId: match.route.id, + params: match.params, loadContext: {}, }); } catch (error) { diff --git a/packages/remix-server-runtime/__tests__/handler-test.ts b/packages/remix-server-runtime/__tests__/handler-test.ts index fd19fb4556a..66ba9a1bfcf 100644 --- a/packages/remix-server-runtime/__tests__/handler-test.ts +++ b/packages/remix-server-runtime/__tests__/handler-test.ts @@ -22,6 +22,7 @@ describe("createRequestHandler", () => { headers: { "X-Foo": "bar", }, + signal: new AbortController().signal, }) ); diff --git a/packages/remix-server-runtime/__tests__/server-test.ts b/packages/remix-server-runtime/__tests__/server-test.ts index 5c691c513a8..1f95e68aeef 100644 --- a/packages/remix-server-runtime/__tests__/server-test.ts +++ b/packages/remix-server-runtime/__tests__/server-test.ts @@ -3,6 +3,8 @@ import { ServerMode } from "../mode"; import type { ServerBuild } from "../build"; import { mockServerBuild } from "./utils"; +const DATA_CALL_MULTIPIER = process.env.ENABLE_REMIX_ROUTER ? 2 : 1; + function spyConsole() { // https://github.com/facebook/react/issues/7047 let spy: any = {}; @@ -121,13 +123,16 @@ describe("shared server runtime", () => { }); let handler = createRequestHandler(build, ServerMode.Test); - let request = new Request(`${baseUrl}/resource`, { method: "get" }); + let request = new Request(`${baseUrl}/resource`, { + method: "get", + signal: new AbortController().signal, + }); let result = await handler(request); expect(result.status).toBe(200); expect(await result.json()).toBe("resource"); expect(rootLoader.mock.calls.length).toBe(0); - expect(resourceLoader.mock.calls.length).toBe(1); + expect(resourceLoader.mock.calls.length).toBe(1 * DATA_CALL_MULTIPIER); }); test("calls sub resource route loader", async () => { @@ -156,14 +161,17 @@ describe("shared server runtime", () => { }); let handler = createRequestHandler(build, ServerMode.Test); - let request = new Request(`${baseUrl}/resource/sub`, { method: "get" }); + let request = new Request(`${baseUrl}/resource/sub`, { + method: "get", + signal: new AbortController().signal, + }); let result = await handler(request); expect(result.status).toBe(200); expect(await result.json()).toBe("sub"); expect(rootLoader.mock.calls.length).toBe(0); expect(resourceLoader.mock.calls.length).toBe(0); - expect(subResourceLoader.mock.calls.length).toBe(1); + expect(subResourceLoader.mock.calls.length).toBe(1 * DATA_CALL_MULTIPIER); }); test("resource route loader allows thrown responses", async () => { @@ -185,13 +193,16 @@ describe("shared server runtime", () => { }); let handler = createRequestHandler(build, ServerMode.Test); - let request = new Request(`${baseUrl}/resource`, { method: "get" }); + let request = new Request(`${baseUrl}/resource`, { + method: "get", + signal: new AbortController().signal, + }); let result = await handler(request); expect(result.status).toBe(200); expect(await result.text()).toBe("resource"); expect(rootLoader.mock.calls.length).toBe(0); - expect(resourceLoader.mock.calls.length).toBe(1); + expect(resourceLoader.mock.calls.length).toBe(1 * DATA_CALL_MULTIPIER); }); test("resource route loader responds with generic error when thrown", async () => { @@ -207,10 +218,13 @@ describe("shared server runtime", () => { }); let handler = createRequestHandler(build, ServerMode.Test); - let request = new Request(`${baseUrl}/resource`, { method: "get" }); + let request = new Request(`${baseUrl}/resource`, { + method: "get", + signal: new AbortController().signal, + }); let result = await handler(request); - expect(await result.text()).toBe("Unexpected Server Error"); + expect(await result.text()).toBe("Unexpected Server Error\n\nError: should be logged when resource loader throws"); }); test("resource route loader responds with detailed error when thrown in development", async () => { @@ -226,11 +240,14 @@ describe("shared server runtime", () => { }); let handler = createRequestHandler(build, ServerMode.Development); - let request = new Request(`${baseUrl}/resource`, { method: "get" }); + let request = new Request(`${baseUrl}/resource`, { + method: "get", + signal: new AbortController().signal, + }); let result = await handler(request); expect((await result.text()).includes(error.message)).toBe(true); - expect(spy.console.mock.calls.length).toBe(1); + expect(spy.console.mock.calls.length).toBe(1 * DATA_CALL_MULTIPIER); }); test("calls resource route action", async () => { @@ -252,13 +269,16 @@ describe("shared server runtime", () => { }); let handler = createRequestHandler(build, ServerMode.Test); - let request = new Request(`${baseUrl}/resource`, { method: "post" }); + let request = new Request(`${baseUrl}/resource`, { + method: "post", + signal: new AbortController().signal, + }); let result = await handler(request); expect(result.status).toBe(200); expect(await result.json()).toBe("resource"); expect(rootAction.mock.calls.length).toBe(0); - expect(resourceAction.mock.calls.length).toBe(1); + expect(resourceAction.mock.calls.length).toBe(1 * DATA_CALL_MULTIPIER); }); test("calls sub resource route action", async () => { @@ -287,14 +307,17 @@ describe("shared server runtime", () => { }); let handler = createRequestHandler(build, ServerMode.Test); - let request = new Request(`${baseUrl}/resource/sub`, { method: "post" }); + let request = new Request(`${baseUrl}/resource/sub`, { + method: "post", + signal: new AbortController().signal, + }); let result = await handler(request); expect(result.status).toBe(200); expect(await result.json()).toBe("sub"); expect(rootAction.mock.calls.length).toBe(0); expect(resourceAction.mock.calls.length).toBe(0); - expect(subResourceAction.mock.calls.length).toBe(1); + expect(subResourceAction.mock.calls.length).toBe(1 * DATA_CALL_MULTIPIER); }); test("resource route action allows thrown responses", async () => { @@ -316,13 +339,16 @@ describe("shared server runtime", () => { }); let handler = createRequestHandler(build, ServerMode.Test); - let request = new Request(`${baseUrl}/resource`, { method: "post" }); + let request = new Request(`${baseUrl}/resource`, { + method: "post", + signal: new AbortController().signal, + }); let result = await handler(request); expect(result.status).toBe(200); expect(await result.text()).toBe("resource"); expect(rootAction.mock.calls.length).toBe(0); - expect(resourceAction.mock.calls.length).toBe(1); + expect(resourceAction.mock.calls.length).toBe(1 * DATA_CALL_MULTIPIER); }); test("resource route action responds with generic error when thrown", async () => { @@ -338,10 +364,13 @@ describe("shared server runtime", () => { }); let handler = createRequestHandler(build, ServerMode.Test); - let request = new Request(`${baseUrl}/resource`, { method: "post" }); + let request = new Request(`${baseUrl}/resource`, { + method: "post", + signal: new AbortController().signal, + }); let result = await handler(request); - expect(await result.text()).toBe("Unexpected Server Error"); + expect(await result.text()).toBe("Unexpected Server Error\n\nError: should be logged when resource loader throws"); }); test("resource route action responds with detailed error when thrown in development", async () => { @@ -357,11 +386,14 @@ describe("shared server runtime", () => { }); let handler = createRequestHandler(build, ServerMode.Development); - let request = new Request(`${baseUrl}/resource`, { method: "post" }); + let request = new Request(`${baseUrl}/resource`, { + method: "post", + signal: new AbortController().signal, + }); let result = await handler(request); expect((await result.text()).includes(message)).toBe(true); - expect(spy.console.mock.calls.length).toBe(1); + expect(spy.console.mock.calls.length).toBe(1 * DATA_CALL_MULTIPIER); }); }); @@ -1604,7 +1636,7 @@ describe("shared server runtime", () => { let result = await handler(request); expect(result.status).toBe(500); - expect(await result.text()).toBe("Unexpected Server Error"); + expect(await result.text()).toBe("Unexpected Server Error\n\nError: rofl"); expect(rootLoader.mock.calls.length).toBe(0); expect(indexLoader.mock.calls.length).toBe(0); diff --git a/packages/remix-server-runtime/data.ts b/packages/remix-server-runtime/data.ts index f5a6ca0f1fe..0a7d1803aac 100644 --- a/packages/remix-server-runtime/data.ts +++ b/packages/remix-server-runtime/data.ts @@ -1,6 +1,9 @@ -import type { RouteMatch } from "./routeMatching"; -import type { ServerRoute } from "./routes"; import { json, isResponse, isRedirectResponse } from "./responses"; +import type { + ActionFunction, + DataFunctionArgs, + LoaderFunction, +} from "./routeModules"; /** * An object of unknown type for route loaders and actions provided by the @@ -17,15 +20,17 @@ export type AppData = any; export async function callRouteAction({ loadContext, - match, + routeId, + action, + params, request, }: { loadContext: AppLoadContext; - match: RouteMatch; + routeId: string; + action?: ActionFunction; + params: DataFunctionArgs["params"]; request: Request; }) { - let action = match.route.module.action; - if (!action) { let response = new Response(null, { status: 405 }); response.headers.set("X-Remix-Catch", "yes"); @@ -37,7 +42,7 @@ export async function callRouteAction({ result = await action({ request: stripDataParam(stripIndexParam(request)), context: loadContext, - params: match.params, + params, }); } catch (error: unknown) { if (!isResponse(error)) { @@ -52,7 +57,7 @@ export async function callRouteAction({ if (result === undefined) { throw new Error( - `You defined an action for route "${match.route.id}" but didn't return ` + + `You defined an action for route "${routeId}" but didn't return ` + `anything from your \`action\` function. Please return a value or \`null\`.` ); } @@ -62,19 +67,21 @@ export async function callRouteAction({ export async function callRouteLoader({ loadContext, - match, + routeId, + loader, + params, request, }: { request: Request; - match: RouteMatch; + routeId: string; + loader?: LoaderFunction; + params: DataFunctionArgs["params"]; loadContext: AppLoadContext; }) { - let loader = match.route.module.loader; - if (!loader) { throw new Error( `You made a ${request.method} request to ${request.url} but did not provide ` + - `a default component or \`loader\` for route "${match.route.id}", ` + + `a default component or \`loader\` for route "${routeId}", ` + `so there is no way to handle the request.` ); } @@ -84,7 +91,7 @@ export async function callRouteLoader({ result = await loader({ request: stripDataParam(stripIndexParam(request)), context: loadContext, - params: match.params, + params, }); } catch (error: unknown) { if (!isResponse(error)) { @@ -99,7 +106,7 @@ export async function callRouteLoader({ if (result === undefined) { throw new Error( - `You defined a loader for route "${match.route.id}" but didn't return ` + + `You defined a loader for route "${routeId}" but didn't return ` + `anything from your \`loader\` function. Please return a value or \`null\`.` ); } diff --git a/packages/remix-server-runtime/rollup.config.js b/packages/remix-server-runtime/rollup.config.js index 256dfeab6ea..d9def48cfcd 100644 --- a/packages/remix-server-runtime/rollup.config.js +++ b/packages/remix-server-runtime/rollup.config.js @@ -3,6 +3,7 @@ const path = require("path"); const babel = require("@rollup/plugin-babel").default; const nodeResolve = require("@rollup/plugin-node-resolve").default; const copy = require("rollup-plugin-copy"); +const replace = require("@rollup/plugin-replace"); const { getOutputDir, @@ -13,6 +14,15 @@ const { } = require("../../rollup.utils"); const { name: packageName, version } = require("./package.json"); +const ENABLE_REMIX_ROUTER = !!process.env.ENABLE_REMIX_ROUTER; + +const replacePlugin = replace({ + preventAssignment: true, + values: { + "process.env.ENABLE_REMIX_ROUTER": ENABLE_REMIX_ROUTER ? "1" : "0", + }, +}); + /** @returns {import("rollup").RollupOptions[]} */ module.exports = function rollup() { let sourceDir = "packages/remix-server-runtime"; @@ -32,7 +42,12 @@ module.exports = function rollup() { preserveModules: true, exports: "named", }, + treeshake: { + // Without this, we don't tree-shake the require('@remix-run/router') :/ + moduleSideEffects: false, + }, plugins: [ + replacePlugin, babel({ babelHelpers: "bundled", exclude: /node_modules/, @@ -61,7 +76,12 @@ module.exports = function rollup() { format: "esm", preserveModules: true, }, + treeshake: { + // Without this, we don't tree-shake the require('@remix-run/router') :/ + moduleSideEffects: false, + }, plugins: [ + replacePlugin, babel({ babelHelpers: "bundled", exclude: /node_modules/, diff --git a/packages/remix-server-runtime/router/history.ts b/packages/remix-server-runtime/router/history.ts new file mode 100644 index 00000000000..7cb1dc95dcf --- /dev/null +++ b/packages/remix-server-runtime/router/history.ts @@ -0,0 +1,625 @@ +// @ts-nocheck + +//////////////////////////////////////////////////////////////////////////////// +//#region Types and Constants +//////////////////////////////////////////////////////////////////////////////// + +/** + * Actions represent the type of change to a location value. + */ +export enum Action { + /** + * A POP indicates a change to an arbitrary index in the history stack, such + * as a back or forward navigation. It does not describe the direction of the + * navigation, only that the current index changed. + * + * Note: This is the default action for newly created history objects. + */ + Pop = "POP", + + /** + * A PUSH indicates a new entry being added to the history stack, such as when + * a link is clicked and a new page loads. When this happens, all subsequent + * entries in the stack are lost. + */ + Push = "PUSH", + + /** + * A REPLACE indicates the entry at the current index in the history stack + * being replaced by a new one. + */ + Replace = "REPLACE", +} + +/** + * The pathname, search, and hash values of a URL. + */ +export interface Path { + /** + * A URL pathname, beginning with a /. + */ + pathname: string; + + /** + * A URL search string, beginning with a ?. + */ + search: string; + + /** + * A URL fragment identifier, beginning with a #. + */ + hash: string; +} + +/** + * An entry in a history stack. A location contains information about the + * URL path, as well as possibly some arbitrary state and a key. + */ +export interface Location extends Path { + /** + * A value of arbitrary data associated with this location. + */ + state: any; + + /** + * A unique string associated with this location. May be used to safely store + * and retrieve data in some other storage API, like `localStorage`. + * + * Note: This value is always "default" on the initial location. + */ + key: string; +} + +/** + * A change to the current location. + */ +export interface Update { + /** + * The action that triggered the change. + */ + action: Action; + + /** + * The new location. + */ + location: Location; +} + +/** + * A function that receives notifications about location changes. + */ +export interface Listener { + (update: Update): void; +} + +/** + * Describes a location that is the destination of some navigation, either via + * `history.push` or `history.replace`. May be either a URL or the pieces of a + * URL path. + */ +export type To = string | Partial; + +/** + * A history is an interface to the navigation stack. The history serves as the + * source of truth for the current location, as well as provides a set of + * methods that may be used to change it. + * + * It is similar to the DOM's `window.history` object, but with a smaller, more + * focused API. + */ +export interface History { + /** + * The last action that modified the current location. This will always be + * Action.Pop when a history instance is first created. This value is mutable. + */ + readonly action: Action; + + /** + * The current location. This value is mutable. + */ + readonly location: Location; + + /** + * Returns a valid href for the given `to` value that may be used as + * the value of an attribute. + * + * @param to - The destination URL + */ + createHref(to: To): string; + + /** + * Pushes a new location onto the history stack, increasing its length by one. + * If there were any entries in the stack after the current one, they are + * lost. + * + * @param to - The new URL + * @param state - Data to associate with the new location + */ + push(to: To, state?: any): void; + + /** + * Replaces the current location in the history stack with a new one. The + * location that was replaced will no longer be available. + * + * @param to - The new URL + * @param state - Data to associate with the new location + */ + replace(to: To, state?: any): void; + + /** + * Navigates `n` entries backward/forward in the history stack relative to the + * current index. For example, a "back" navigation would use go(-1). + * + * @param delta - The delta in the stack index + */ + go(delta: number): void; + + /** + * Sets up a listener that will be called whenever the current location + * changes. + * + * @param listener - A function that will be called when the location changes + * @returns unlisten - A function that may be used to stop listening + */ + listen(listener: Listener): () => void; +} + +type HistoryState = { + usr: any; + key?: string; +}; + +const PopStateEventType = "popstate"; +//#endregion + +//////////////////////////////////////////////////////////////////////////////// +//#region Memory History +//////////////////////////////////////////////////////////////////////////////// + +/** + * A user-supplied object that describes a location. Used when providing + * entries to `createMemoryHistory` via its `initialEntries` option. + */ +export type InitialEntry = string | Partial; + +export type MemoryHistoryOptions = { + initialEntries?: InitialEntry[]; + initialIndex?: number; + v5Compat?: boolean; +}; + +/** + * A memory history stores locations in memory. This is useful in stateful + * environments where there is no web browser, such as node tests or React + * Native. + */ +export interface MemoryHistory extends History { + /** + * The current index in the history stack. + */ + readonly index: number; +} + +/** + * Memory history stores the current location in memory. It is designed for use + * in stateful non-browser environments like tests and React Native. + */ +export function createMemoryHistory( + options: MemoryHistoryOptions = {} +): MemoryHistory { + let { initialEntries = ["/"], initialIndex, v5Compat = false } = options; + let entries: Location[]; // Declare so we can access from createMemoryLocation + entries = initialEntries.map((entry, index) => + createMemoryLocation( + entry, + typeof entry === "string" ? null : entry.state, + index === 0 ? "default" : undefined + ) + ); + let index = clampIndex( + initialIndex == null ? entries.length - 1 : initialIndex + ); + let action = Action.Pop; + let listener: Listener | null = null; + + function clampIndex(n: number): number { + return Math.min(Math.max(n, 0), entries.length - 1); + } + function getCurrentLocation(): Location { + return entries[index]; + } + function createMemoryLocation( + to: To, + state: any = null, + key?: string + ): Location { + let location = createLocation( + entries ? getCurrentLocation().pathname : "/", + to, + state, + key + ); + warning( + location.pathname.charAt(0) === "/", + `relative pathnames are not supported in memory history: ${JSON.stringify( + to + )}` + ); + return location; + } + + let history: MemoryHistory = { + get index() { + return index; + }, + get action() { + return action; + }, + get location() { + return getCurrentLocation(); + }, + createHref(to) { + return typeof to === "string" ? to : createPath(to); + }, + push(to, state) { + action = Action.Push; + let nextLocation = createMemoryLocation(to, state); + index += 1; + entries.splice(index, entries.length, nextLocation); + if (v5Compat && listener) { + listener({ action, location: nextLocation }); + } + }, + replace(to, state) { + action = Action.Replace; + let nextLocation = createMemoryLocation(to, state); + entries[index] = nextLocation; + if (v5Compat && listener) { + listener({ action, location: nextLocation }); + } + }, + go(delta) { + action = Action.Pop; + index = clampIndex(index + delta); + if (listener) { + listener({ action, location: getCurrentLocation() }); + } + }, + listen(fn: Listener) { + listener = fn; + return () => { + listener = null; + }; + }, + }; + + return history; +} +//#endregion + +//////////////////////////////////////////////////////////////////////////////// +//#region Browser History +//////////////////////////////////////////////////////////////////////////////// + +/** + * A browser history stores the current location in regular URLs in a web + * browser environment. This is the standard for most web apps and provides the + * cleanest URLs the browser's address bar. + * + * @see https://github.com/remix-run/history/tree/main/docs/api-reference.md#browserhistory + */ +export interface BrowserHistory extends UrlHistory {} + +export type BrowserHistoryOptions = UrlHistoryOptions; + +/** + * Browser history stores the location in regular URLs. This is the standard for + * most web apps, but it requires some configuration on the server to ensure you + * serve the same app at multiple URLs. + * + * @see https://github.com/remix-run/history/tree/main/docs/api-reference.md#createbrowserhistory + */ +export function createBrowserHistory( + options: BrowserHistoryOptions = {} +): BrowserHistory { + function createBrowserLocation( + window: Window, + globalHistory: Window["history"] + ) { + let { pathname, search, hash } = window.location; + return createLocation( + "", + { pathname, search, hash }, + // state defaults to `null` because `window.history.state` does + (globalHistory.state && globalHistory.state.usr) || null, + (globalHistory.state && globalHistory.state.key) || "default" + ); + } + + function createBrowserHref(window: Window, to: To) { + return typeof to === "string" ? to : createPath(to); + } + + return getUrlBasedHistory( + createBrowserLocation, + createBrowserHref, + null, + options + ); +} +//#endregion + +//////////////////////////////////////////////////////////////////////////////// +//#region Hash History +//////////////////////////////////////////////////////////////////////////////// + +/** + * A hash history stores the current location in the fragment identifier portion + * of the URL in a web browser environment. + * + * This is ideal for apps that do not control the server for some reason + * (because the fragment identifier is never sent to the server), including some + * shared hosting environments that do not provide fine-grained controls over + * which pages are served at which URLs. + * + * @see https://github.com/remix-run/history/tree/main/docs/api-reference.md#hashhistory + */ +export interface HashHistory extends UrlHistory {} + +export type HashHistoryOptions = UrlHistoryOptions; + +/** + * Hash history stores the location in window.location.hash. This makes it ideal + * for situations where you don't want to send the location to the server for + * some reason, either because you do cannot configure it or the URL space is + * reserved for something else. + * + * @see https://github.com/remix-run/history/tree/main/docs/api-reference.md#createhashhistory + */ +export function createHashHistory( + options: HashHistoryOptions = {} +): HashHistory { + function createHashLocation( + window: Window, + globalHistory: Window["history"] + ) { + let { + pathname = "/", + search = "", + hash = "", + } = parsePath(window.location.hash.substr(1)); + return createLocation( + "", + { pathname, search, hash }, + // state defaults to `null` because `window.history.state` does + (globalHistory.state && globalHistory.state.usr) || null, + (globalHistory.state && globalHistory.state.key) || "default" + ); + } + + function createHashHref(window: Window, to: To) { + let base = window.document.querySelector("base"); + let href = ""; + + if (base && base.getAttribute("href")) { + let url = window.location.href; + let hashIndex = url.indexOf("#"); + href = hashIndex === -1 ? url : url.slice(0, hashIndex); + } + + return href + "#" + (typeof to === "string" ? to : createPath(to)); + } + + function validateHashLocation(location: Location, to: To) { + warning( + location.pathname.charAt(0) === "/", + `relative pathnames are not supported in hash history.push(${JSON.stringify( + to + )})` + ); + } + + return getUrlBasedHistory( + createHashLocation, + createHashHref, + validateHashLocation, + options + ); +} +//#endregion + +//////////////////////////////////////////////////////////////////////////////// +//#region UTILS +//////////////////////////////////////////////////////////////////////////////// + +function warning(cond: any, message: string) { + if (!cond) { + // eslint-disable-next-line no-console + if (typeof console !== "undefined") console.warn(message); + + try { + // Welcome to debugging history! + // + // This error is thrown as a convenience so you can more easily + // find the source for a warning that appears in the console by + // enabling "pause on exceptions" in your JavaScript debugger. + throw new Error(message); + // eslint-disable-next-line no-empty + } catch (e) {} + } +} + +function createKey() { + return Math.random().toString(36).substr(2, 8); +} + +/** + * For browser-based histories, we combine the state and key into an object + */ +function getHistoryState(location: Location): HistoryState { + return { + usr: location.state, + key: location.key, + }; +} + +/** + * Creates a Location object with a unique key from the given Path + */ +export function createLocation( + current: string | Location, + to: To, + state: any = null, + key?: string +): Readonly { + let location: Readonly = { + pathname: typeof current === "string" ? current : current.pathname, + search: "", + hash: "", + ...(typeof to === "string" ? parsePath(to) : to), + state, + // TODO: This could be cleaned up. push/replace should probably just take + // full Locations now and avoid the need to run through this flow at all + // But that's a pretty big refactor to the current test suite so going to + // keep as is for the time being and just let any incoming keys take precedence + key: (to && (to as Location).key) || key || createKey(), + }; + return location; +} + +/** + * Creates a string URL path from the given pathname, search, and hash components. + */ +export function createPath({ + pathname = "/", + search = "", + hash = "", +}: Partial) { + if (search && search !== "?") + pathname += search.charAt(0) === "?" ? search : "?" + search; + if (hash && hash !== "#") + pathname += hash.charAt(0) === "#" ? hash : "#" + hash; + return pathname; +} + +/** + * Parses a string URL path into its separate pathname, search, and hash components. + */ +export function parsePath(path: string): Partial { + let parsedPath: Partial = {}; + + if (path) { + let hashIndex = path.indexOf("#"); + if (hashIndex >= 0) { + parsedPath.hash = path.substr(hashIndex); + path = path.substr(0, hashIndex); + } + + let searchIndex = path.indexOf("?"); + if (searchIndex >= 0) { + parsedPath.search = path.substr(searchIndex); + path = path.substr(0, searchIndex); + } + + if (path) { + parsedPath.pathname = path; + } + } + + return parsedPath; +} + +export interface UrlHistory extends History {} + +export type UrlHistoryOptions = { + window?: Window; + v5Compat?: boolean; +}; + +function getUrlBasedHistory( + getLocation: (window: Window, globalHistory: Window["history"]) => Location, + createHref: (window: Window, to: To) => string, + validateLocation: ((location: Location, to: To) => void) | null, + options: UrlHistoryOptions = {} +): UrlHistory { + let { window = document.defaultView!, v5Compat = false } = options; + let globalHistory = window.history; + let action = Action.Pop; + let listener: Listener | null = null; + + function handlePop() { + action = Action.Pop; + if (listener) { + listener({ action, location: history.location }); + } + } + + function push(to: To, state?: any) { + action = Action.Push; + let location = createLocation(history.location, to, state); + if (validateLocation) validateLocation(location, to); + + let historyState = getHistoryState(location); + let url = history.createHref(location); + + // try...catch because iOS limits us to 100 pushState calls :/ + try { + globalHistory.pushState(historyState, "", url); + } catch (error) { + // They are going to lose state here, but there is no real + // way to warn them about it since the page will refresh... + window.location.assign(url); + } + + if (v5Compat && listener) { + listener({ action, location }); + } + } + + function replace(to: To, state?: any) { + action = Action.Replace; + let location = createLocation(history.location, to, state); + if (validateLocation) validateLocation(location, to); + + let historyState = getHistoryState(location); + let url = history.createHref(location); + globalHistory.replaceState(historyState, "", url); + + if (v5Compat && listener) { + listener({ action, location: location }); + } + } + + let history: History = { + get action() { + return action; + }, + get location() { + return getLocation(window, globalHistory); + }, + listen(fn: Listener) { + if (listener) { + throw new Error("A history only accepts one active listener"); + } + window.addEventListener(PopStateEventType, handlePop); + listener = fn; + + return () => { + window.removeEventListener(PopStateEventType, handlePop); + listener = null; + }; + }, + createHref(to) { + return createHref(window, to); + }, + push, + replace, + go(n) { + return globalHistory.go(n); + }, + }; + + return history; +} + +//#endregion diff --git a/packages/remix-server-runtime/router/index.ts b/packages/remix-server-runtime/router/index.ts new file mode 100644 index 00000000000..cc57bff8489 --- /dev/null +++ b/packages/remix-server-runtime/router/index.ts @@ -0,0 +1,80 @@ +// @ts-nocheck + +import { convertRoutesToDataRoutes } from "./utils"; + +export type { + ActionFunction, + ActionFunctionArgs, + AgnosticDataRouteMatch, + AgnosticDataRouteObject, + AgnosticRouteMatch, + AgnosticRouteObject, + TrackedPromise, + FormEncType, + FormMethod, + JsonFunction, + LoaderFunction, + LoaderFunctionArgs, + ParamParseKey, + Params, + PathMatch, + PathPattern, + RedirectFunction, + ShouldRevalidateFunction, + Submission, +} from "./utils"; + +export { + AbortedDeferredError, + ErrorResponse, + defer, + generatePath, + getToPathname, + invariant, + isRouteErrorResponse, + joinPaths, + json, + matchPath, + matchRoutes, + normalizePathname, + redirect, + resolvePath, + resolveTo, + stripBasename, + warning, +} from "./utils"; + +export type { + BrowserHistory, + BrowserHistoryOptions, + HashHistory, + HashHistoryOptions, + History, + InitialEntry, + Location, + MemoryHistory, + MemoryHistoryOptions, + Path, + To, +} from "./history"; + +export { + Action, + createBrowserHistory, + createPath, + createHashHistory, + createMemoryHistory, + parsePath, +} from "./history"; + +export * from "./router"; + +/////////////////////////////////////////////////////////////////////////////// +// DANGER! PLEASE READ ME! +// We consider these exports an implementation detail and do not guarantee +// against any breaking changes, regardless of the semver release. Use with +// extreme caution and only if you understand the consequences. Godspeed. +/////////////////////////////////////////////////////////////////////////////// + +/** @internal */ +export { convertRoutesToDataRoutes as UNSAFE_convertRoutesToDataRoutes }; diff --git a/packages/remix-server-runtime/router/router.ts b/packages/remix-server-runtime/router/router.ts new file mode 100644 index 00000000000..3305f5ac7b5 --- /dev/null +++ b/packages/remix-server-runtime/router/router.ts @@ -0,0 +1,2857 @@ +// @ts-nocheck +// eslint-disable + +import type { History, Location, Path, To } from "./history"; +import { + Action as HistoryAction, + createLocation, + createPath, + parsePath, +} from "./history"; +import type { + DataResult, + AgnosticDataRouteMatch, + AgnosticDataRouteObject, + DeferredResult, + ErrorResult, + FormEncType, + FormMethod, + RedirectResult, + RouteData, + AgnosticRouteObject, + Submission, + SuccessResult, + AgnosticRouteMatch, +} from "./utils"; +import { + DeferredData, + ErrorResponse, + ResultType, + convertRoutesToDataRoutes, + invariant, + isRouteErrorResponse, + matchRoutes, +} from "./utils"; + +//////////////////////////////////////////////////////////////////////////////// +//#region Types and Constants +//////////////////////////////////////////////////////////////////////////////// + +/** + * A Router instance manages all navigation and data loading/mutations + */ +export interface Router { + /** + * @internal + * PRIVATE - DO NOT USE + * + * Return the basename for the router + */ + get basename(): RouterInit["basename"]; + + /** + * @internal + * PRIVATE - DO NOT USE + * + * Return the current state of the router + */ + get state(): RouterState; + + /** + * @internal + * PRIVATE - DO NOT USE + * + * Return the routes for this router instance + */ + get routes(): AgnosticDataRouteObject[]; + + /** + * @internal + * PRIVATE - DO NOT USE + * + * Initialize the router, including adding history listeners and kicking off + * initial data fetches. Returns a function to cleanup listeners and abort + * any in-progress loads + */ + initialize(): Router; + + /** + * @internal + * PRIVATE - DO NOT USE + * + * Subscribe to router.state updates + * + * @param fn function to call with the new state + */ + subscribe(fn: RouterSubscriber): () => void; + + /** + * @internal + * PRIVATE - DO NOT USE + * + * Enable scroll restoration behavior in the router + * + * @param savedScrollPositions Object that will manage positions, in case + * it's being restored from sessionStorage + * @param getScrollPosition Function to get the active Y scroll position + * @param getKey Function to get the key to use for restoration + */ + enableScrollRestoration( + savedScrollPositions: Record, + getScrollPosition: GetScrollPositionFunction, + getKey?: GetScrollRestorationKeyFunction + ): () => void; + + /** + * @internal + * PRIVATE - DO NOT USE + * + * Navigate forward/backward in the history stack + * @param to Delta to move in the history stack + */ + navigate(to: number): void; + + /** + * Navigate to the given path + * @param to Path to navigate to + * @param opts Navigation options (method, submission, etc.) + */ + navigate(to: To, opts?: RouterNavigateOptions): void; + + /** + * @internal + * PRIVATE - DO NOT USE + * + * Trigger a fetcher load/submission + * + * @param key Fetcher key + * @param routeId Route that owns the fetcher + * @param href href to fetch + * @param opts Fetcher options, (method, submission, etc.) + */ + fetch( + key: string, + routeId: string, + href: string, + opts?: RouterNavigateOptions + ): void; + + /** + * @internal + * PRIVATE - DO NOT USE + * + * Trigger a revalidation of all current route loaders and fetcher loads + */ + revalidate(): void; + + /** + * @internal + * PRIVATE - DO NOT USE + * + * Utility function to create an href for the given location + * @param location + */ + createHref(location: Location | URL): string; + + /** + * @internal + * PRIVATE - DO NOT USE + * + * Get/create a fetcher for the given key + * @param key + */ + getFetcher(key?: string): Fetcher; + + /** + * @internal + * PRIVATE - DO NOT USE + * + * Delete the fetcher for a given key + * @param key + */ + deleteFetcher(key?: string): void; + + /** + * @internal + * PRIVATE - DO NOT USE + * + * Cleanup listeners and abort any in-progress loads + */ + dispose(): void; + + /** + * @internal + * PRIVATE - DO NOT USE + * + * Internal fetch AbortControllers accessed by unit tests + */ + _internalFetchControllers: Map; + + /** + * @internal + * PRIVATE - DO NOT USE + * + * Internal pending DeferredData instances accessed by unit tests + */ + _internalActiveDeferreds: Map; +} + +/** + * State maintained internally by the router. During a navigation, all states + * reflect the the "old" location unless otherwise noted. + */ +export interface RouterState { + /** + * The action of the most recent navigation + */ + historyAction: HistoryAction; + + /** + * The current location reflected by the router + */ + location: Location; + + /** + * The current set of route matches + */ + matches: AgnosticDataRouteMatch[]; + + /** + * Tracks whether we've completed our initial data load + */ + initialized: boolean; + + /** + * Current scroll position we should start at for a new view + * - number -> scroll position to restore to + * - false -> do not restore scroll at all (used during submissions) + * - null -> don't have a saved position, scroll to hash or top of page + */ + restoreScrollPosition: number | false | null; + + /** + * Indicate whether this navigation should skip resetting the scroll position + * if we are unable to restore the scroll position + */ + preventScrollReset: boolean; + + /** + * Tracks the state of the current navigation + */ + navigation: Navigation; + + /** + * Tracks any in-progress revalidations + */ + revalidation: RevalidationState; + + /** + * Data from the loaders for the current matches + */ + loaderData: RouteData; + + /** + * Data from the action for the current matches + */ + actionData: RouteData | null; + + /** + * Errors caught from loaders for the current matches + */ + errors: RouteData | null; + + /** + * Map of current fetchers + */ + fetchers: Map; +} + +/** + * Data that can be passed into hydrate a Router from SSR + */ +export type HydrationState = Partial< + Pick +>; + +/** + * Initialization options for createRouter + */ +export interface RouterInit { + basename?: string; + routes: AgnosticRouteObject[]; + history: History; + hydrationData?: HydrationState; +} + +/** + * State returned from a server-side query() call + */ +export interface StaticHandlerContext { + location: RouterState["location"]; + matches: RouterState["matches"]; + loaderData: RouterState["loaderData"]; + actionData: RouterState["actionData"]; + errors: RouterState["errors"]; + statusCode: number; + loaderHeaders: Record; + actionHeaders: Record; + _deepestRenderedBoundaryId?: string | null; +} + +/** + * A StaticHandler instance manages a singular SSR navigation/fetch event + */ +export interface StaticHandler { + dataRoutes: AgnosticDataRouteObject[]; + query(request: Request): Promise; + queryRoute(request: Request, routeId?: string): Promise; +} + +/** + * Subscriber function signature for changes to router state + */ +export interface RouterSubscriber { + (state: RouterState): void; +} + +interface UseMatchesMatch { + id: string; + pathname: string; + params: AgnosticRouteMatch["params"]; + data: unknown; + handle: unknown; +} + +/** + * Function signature for determining the key to be used in scroll restoration + * for a given location + */ +export interface GetScrollRestorationKeyFunction { + (location: Location, matches: UseMatchesMatch[]): string | null; +} + +/** + * Function signature for determining the current scroll position + */ +export interface GetScrollPositionFunction { + (): number; +} + +/** + * Options for a navigate() call for a Link navigation + */ +type LinkNavigateOptions = { + replace?: boolean; + state?: any; + preventScrollReset?: boolean; +}; + +/** + * Options for a navigate() call for a Form navigation + */ +type SubmissionNavigateOptions = { + replace?: boolean; + state?: any; + formMethod?: FormMethod; + formEncType?: FormEncType; + formData: FormData; +}; + +/** + * Options to pass to navigate() for either a Link or Form navigation + */ +export type RouterNavigateOptions = + | LinkNavigateOptions + | SubmissionNavigateOptions; + +/** + * Options to pass to fetch() + */ +export type RouterFetchOptions = + | Omit + | Omit; + +/** + * Potential states for state.navigation + */ +export type NavigationStates = { + Idle: { + state: "idle"; + location: undefined; + formMethod: undefined; + formAction: undefined; + formEncType: undefined; + formData: undefined; + }; + Loading: { + state: "loading"; + location: Location; + formMethod: FormMethod | undefined; + formAction: string | undefined; + formEncType: FormEncType | undefined; + formData: FormData | undefined; + }; + Submitting: { + state: "submitting"; + location: Location; + formMethod: FormMethod; + formAction: string; + formEncType: FormEncType; + formData: FormData; + }; +}; + +export type Navigation = NavigationStates[keyof NavigationStates]; + +export type RevalidationState = "idle" | "loading"; + +/** + * Potential states for fetchers + */ +type FetcherStates = { + Idle: { + state: "idle"; + formMethod: undefined; + formAction: undefined; + formEncType: undefined; + formData: undefined; + data: TData | undefined; + }; + Loading: { + state: "loading"; + formMethod: FormMethod | undefined; + formAction: string | undefined; + formEncType: FormEncType | undefined; + formData: FormData | undefined; + data: TData | undefined; + }; + Submitting: { + state: "submitting"; + formMethod: FormMethod; + formAction: string; + formEncType: FormEncType; + formData: FormData; + data: TData | undefined; + }; +}; + +export type Fetcher = + FetcherStates[keyof FetcherStates]; + +interface ShortCircuitable { + /** + * startNavigation does not need to complete the navigation because we + * redirected or got interrupted + */ + shortCircuited?: boolean; +} + +interface HandleActionResult extends ShortCircuitable { + /** + * Error thrown from the current action, keyed by the route containing the + * error boundary to render the error. To be committed to the state after + * loaders have completed + */ + pendingActionError?: RouteData; + /** + * Data returned from the current action, keyed by the route owning the action. + * To be committed to the state after loaders have completed + */ + pendingActionData?: RouteData; +} + +interface HandleLoadersResult extends ShortCircuitable { + /** + * loaderData returned from the current set of loaders + */ + loaderData?: RouterState["loaderData"]; + /** + * errors thrown from the current set of loaders + */ + errors?: RouterState["errors"]; +} + +/** + * Tuple of [key, href, DataRouterMatch] for a revalidating fetcher.load() + */ +type RevalidatingFetcher = [string, string, AgnosticDataRouteMatch]; + +/** + * Tuple of [href, DataRouteMatch] for an active fetcher.load() + */ +type FetchLoadMatch = [string, AgnosticDataRouteMatch]; + +export const IDLE_NAVIGATION: NavigationStates["Idle"] = { + state: "idle", + location: undefined, + formMethod: undefined, + formAction: undefined, + formEncType: undefined, + formData: undefined, +}; + +export const IDLE_FETCHER: FetcherStates["Idle"] = { + state: "idle", + data: undefined, + formMethod: undefined, + formAction: undefined, + formEncType: undefined, + formData: undefined, +}; +//#endregion + +//////////////////////////////////////////////////////////////////////////////// +//#region createRouter +//////////////////////////////////////////////////////////////////////////////// + +/** + * Create a router and listen to history POP navigations + */ +export function createRouter(init: RouterInit): Router { + invariant( + init.routes.length > 0, + "You must provide a non-empty routes array to createRouter" + ); + + let dataRoutes = convertRoutesToDataRoutes(init.routes); + // Cleanup function for history + let unlistenHistory: (() => void) | null = null; + // Externally-provided functions to call on all state changes + let subscribers = new Set(); + // Externally-provided object to hold scroll restoration locations during routing + let savedScrollPositions: Record | null = null; + // Externally-provided function to get scroll restoration keys + let getScrollRestorationKey: GetScrollRestorationKeyFunction | null = null; + // Externally-provided function to get current scroll position + let getScrollPosition: GetScrollPositionFunction | null = null; + // One-time flag to control the initial hydration scroll restoration. Because + // we don't get the saved positions from until _after_ + // the initial render, we need to manually trigger a separate updateState to + // send along the restoreScrollPosition + let initialScrollRestored = false; + + let initialMatches = matchRoutes( + dataRoutes, + init.history.location, + init.basename + ); + let initialErrors: RouteData | null = null; + + if (initialMatches == null) { + // If we do not match a user-provided-route, fall back to the root + // to allow the error boundary to take over + let { matches, route, error } = getNotFoundMatches(dataRoutes); + initialMatches = matches; + initialErrors = { [route.id]: error }; + } + + let initialized = + !initialMatches.some((m) => m.route.loader) || init.hydrationData != null; + + let router: Router; + let state: RouterState = { + historyAction: init.history.action, + location: init.history.location, + matches: initialMatches, + initialized, + navigation: IDLE_NAVIGATION, + restoreScrollPosition: null, + preventScrollReset: false, + revalidation: "idle", + loaderData: (init.hydrationData && init.hydrationData.loaderData) || {}, + actionData: (init.hydrationData && init.hydrationData.actionData) || null, + errors: (init.hydrationData && init.hydrationData.errors) || initialErrors, + fetchers: new Map(), + }; + + // -- Stateful internal variables to manage navigations -- + // Current navigation in progress (to be committed in completeNavigation) + let pendingAction: HistoryAction = HistoryAction.Pop; + // Should the current navigation prevent the scroll reset if scroll cannot + // be restored? + let pendingPreventScrollReset = false; + // AbortController for the active navigation + let pendingNavigationController: AbortController | null; + // We use this to avoid touching history in completeNavigation if a + // revalidation is entirely uninterrupted + let isUninterruptedRevalidation = false; + // Use this internal flag to force revalidation of all loaders: + // - submissions (completed or interrupted) + // - useRevalidate() + // - X-Remix-Revalidate (from redirect) + let isRevalidationRequired = false; + // Use this internal array to capture routes that require revalidation due + // to a cancelled deferred on action submission + let cancelledDeferredRoutes: string[] = []; + // Use this internal array to capture fetcher loads that were cancelled by an + // action navigation and require revalidation + let cancelledFetcherLoads: string[] = []; + // AbortControllers for any in-flight fetchers + let fetchControllers = new Map(); + // Track loads based on the order in which they started + let incrementingLoadId = 0; + // Track the outstanding pending navigation data load to be compared against + // the globally incrementing load when a fetcher load lands after a completed + // navigation + let pendingNavigationLoadId = -1; + // Fetchers that triggered data reloads as a result of their actions + let fetchReloadIds = new Map(); + // Fetchers that triggered redirect navigations from their actions + let fetchRedirectIds = new Set(); + // Most recent href/match for fetcher.load calls for fetchers + let fetchLoadMatches = new Map(); + // Store DeferredData instances for active route matches. When a + // route loader returns defer() we stick one in here. Then, when a nested + // promise resolves we update loaderData. If a new navigation starts we + // cancel active deferreds for eliminated routes. + let activeDeferreds = new Map(); + + // Initialize the router, all side effects should be kicked off from here. + // Implemented as a Fluent API for ease of: + // let router = createRouter(init).initialize(); + function initialize() { + // If history informs us of a POP navigation, start the navigation but do not update + // state. We'll update our own state once the navigation completes + unlistenHistory = init.history.listen( + ({ action: historyAction, location }) => + startNavigation(historyAction, location) + ); + + // Kick off initial data load if needed. Use Pop to avoid modifying history + if (!state.initialized) { + startNavigation(HistoryAction.Pop, state.location); + } + + return router; + } + + // Clean up a router and it's side effects + function dispose() { + if (unlistenHistory) { + unlistenHistory(); + } + subscribers.clear(); + pendingNavigationController && pendingNavigationController.abort(); + state.fetchers.forEach((_, key) => deleteFetcher(key)); + } + + // Subscribe to state updates for the router + function subscribe(fn: RouterSubscriber) { + subscribers.add(fn); + return () => subscribers.delete(fn); + } + + // Update our state and notify the calling context of the change + function updateState(newState: Partial): void { + state = { + ...state, + ...newState, + }; + subscribers.forEach((subscriber) => subscriber(state)); + } + + // Complete a navigation returning the state.navigation back to the IDLE_NAVIGATION + // and setting state.[historyAction/location/matches] to the new route. + // - Location is a required param + // - Navigation will always be set to IDLE_NAVIGATION + // - Can pass any other state in newState + function completeNavigation( + location: Location, + newState: Partial> + ): void { + // Deduce if we're in a loading/actionReload state: + // - We have committed actionData in the store + // - The current navigation was a submission + // - We're past the submitting state and into the loading state + // - The location we've finished loading is different from the submission + // location, indicating we redirected from the action (avoids false + // positives for loading/submissionRedirect when actionData returned + // on a prior submission) + let isActionReload = + state.actionData != null && + state.navigation.formMethod != null && + state.navigation.state === "loading" && + state.navigation.formAction?.split("?")[0] === location.pathname; + + // Always preserve any existing loaderData from re-used routes + let newLoaderData = newState.loaderData + ? { + loaderData: mergeLoaderData( + state.loaderData, + newState.loaderData, + newState.matches || [] + ), + } + : {}; + + updateState({ + // Clear existing actionData on any completed navigation beyond the original + // action, unless we're currently finishing the loading/actionReload state. + // Do this prior to spreading in newState in case we got back to back actions + ...(isActionReload ? {} : { actionData: null }), + ...newState, + ...newLoaderData, + historyAction: pendingAction, + location, + initialized: true, + navigation: IDLE_NAVIGATION, + revalidation: "idle", + // Don't restore on submission navigations + restoreScrollPosition: state.navigation.formData + ? false + : getSavedScrollPosition(location, newState.matches || state.matches), + preventScrollReset: pendingPreventScrollReset, + }); + + if (isUninterruptedRevalidation) { + // If this was an uninterrupted revalidation then do not touch history + } else if (pendingAction === HistoryAction.Pop) { + // Do nothing for POP - URL has already been updated + } else if (pendingAction === HistoryAction.Push) { + init.history.push(location, location.state); + } else if (pendingAction === HistoryAction.Replace) { + init.history.replace(location, location.state); + } + + // Reset stateful navigation vars + pendingAction = HistoryAction.Pop; + pendingPreventScrollReset = false; + isUninterruptedRevalidation = false; + isRevalidationRequired = false; + cancelledDeferredRoutes = []; + cancelledFetcherLoads = []; + } + + // Trigger a navigation event, which can either be a numerical POP or a PUSH + // replace with an optional submission + async function navigate( + to: number | To, + opts?: RouterNavigateOptions + ): Promise { + if (typeof to === "number") { + init.history.go(to); + return; + } + + let { path, submission, error } = normalizeNavigateOptions(to, opts); + + let location = createLocation(state.location, path, opts && opts.state); + let historyAction = + (opts && opts.replace) === true || submission != null + ? HistoryAction.Replace + : HistoryAction.Push; + let preventScrollReset = + opts && "preventScrollReset" in opts + ? opts.preventScrollReset === true + : undefined; + + return await startNavigation(historyAction, location, { + submission, + // Send through the formData serialization error if we have one so we can + // render at the right error boundary after we match routes + pendingError: error, + preventScrollReset, + replace: opts && opts.replace, + }); + } + + // Revalidate all current loaders. If a navigation is in progress or if this + // is interrupted by a navigation, allow this to "succeed" by calling all + // loaders during the next loader round + function revalidate() { + interruptActiveLoads(); + updateState({ revalidation: "loading" }); + + // If we're currently submitting an action, we don't need to start a new + // navigation, we'll just let the follow up loader execution call all loaders + if (state.navigation.state === "submitting") { + return; + } + + // If we're currently in an idle state, start a new navigation for the current + // action/location and mark it as uninterrupted, which will skip the history + // update in completeNavigation + if (state.navigation.state === "idle") { + startNavigation(state.historyAction, state.location, { + startUninterruptedRevalidation: true, + }); + return; + } + + // Otherwise, if we're currently in a loading state, just start a new + // navigation to the navigation.location but do not trigger an uninterrupted + // revalidation so that history correctly updates once the navigation completes + startNavigation( + pendingAction || state.historyAction, + state.navigation.location, + { overrideNavigation: state.navigation } + ); + } + + // Start a navigation to the given action/location. Can optionally provide a + // overrideNavigation which will override the normalLoad in the case of a redirect + // navigation + async function startNavigation( + historyAction: HistoryAction, + location: Location, + opts?: { + submission?: Submission; + overrideNavigation?: Navigation; + pendingError?: ErrorResponse; + startUninterruptedRevalidation?: boolean; + preventScrollReset?: boolean; + replace?: boolean; + } + ): Promise { + // Abort any in-progress navigations and start a new one. Unset any ongoing + // uninterrupted revalidations unless told otherwise, since we want this + // new navigation to update history normally + pendingNavigationController && pendingNavigationController.abort(); + pendingNavigationController = null; + pendingAction = historyAction; + isUninterruptedRevalidation = + (opts && opts.startUninterruptedRevalidation) === true; + + // Save the current scroll position every time we start a new navigation, + // and track whether we should reset scroll on completion + saveScrollPosition(state.location, state.matches); + pendingPreventScrollReset = (opts && opts.preventScrollReset) === true; + + let loadingNavigation = opts && opts.overrideNavigation; + let matches = matchRoutes(dataRoutes, location, init.basename); + + // Short circuit with a 404 on the root error boundary if we match nothing + if (!matches) { + let { + matches: notFoundMatches, + route, + error, + } = getNotFoundMatches(dataRoutes); + // Cancel all pending deferred on 404s since we don't keep any routes + cancelActiveDeferreds(); + completeNavigation(location, { + matches: notFoundMatches, + loaderData: {}, + errors: { + [route.id]: error, + }, + }); + return; + } + + // Short circuit if it's only a hash change + if (isHashChangeOnly(state.location, location)) { + completeNavigation(location, { matches }); + return; + } + + // Create a controller/Request for this navigation + pendingNavigationController = new AbortController(); + let request = createRequest( + location, + pendingNavigationController.signal, + opts && opts.submission + ); + let pendingActionData: RouteData | undefined; + let pendingError: RouteData | undefined; + + if (opts && opts.pendingError) { + // If we have a pendingError, it means the user attempted a GET submission + // with binary FormData so assign here and skip to handleLoaders. That + // way we handle calling loaders above the boundary etc. It's not really + // different from an actionError in that sense. + pendingError = { + [findNearestBoundary(matches).route.id]: opts.pendingError, + }; + } else if (opts && opts.submission) { + // Call action if we received an action submission + let actionOutput = await handleAction( + request, + location, + opts.submission, + matches, + { replace: opts.replace } + ); + + if (actionOutput.shortCircuited) { + return; + } + + pendingActionData = actionOutput.pendingActionData; + pendingError = actionOutput.pendingActionError; + + let navigation: NavigationStates["Loading"] = { + state: "loading", + location, + ...opts.submission, + }; + loadingNavigation = navigation; + } + + // Call loaders + let { shortCircuited, loaderData, errors } = await handleLoaders( + request, + location, + matches, + loadingNavigation, + opts && opts.submission, + opts && opts.replace, + pendingActionData, + pendingError + ); + + if (shortCircuited) { + return; + } + + // Clean up now that the action/loaders have completed. Don't clean up if + // we short circuited because pendingNavigationController will have already + // been assigned to a new controller for the next navigation + pendingNavigationController = null; + + completeNavigation(location, { + matches, + loaderData, + errors, + }); + } + + // Call the action matched by the leaf route for this navigation and handle + // redirects/errors + async function handleAction( + request: Request, + location: Location, + submission: Submission, + matches: AgnosticDataRouteMatch[], + opts?: { replace?: boolean } + ): Promise { + interruptActiveLoads(); + + // Put us in a submitting state + let navigation: NavigationStates["Submitting"] = { + state: "submitting", + location, + ...submission, + }; + updateState({ navigation }); + + // Call our action and get the result + let result: DataResult; + let actionMatch = getTargetMatch(matches, location); + + if (!actionMatch.route.action) { + result = getMethodNotAllowedResult(location); + } else { + result = await callLoaderOrAction("action", request, actionMatch); + + if (request.signal.aborted) { + return { shortCircuited: true }; + } + } + + if (isRedirectResult(result)) { + let redirectNavigation: NavigationStates["Loading"] = { + state: "loading", + location: createLocation(state.location, result.location), + ...submission, + }; + await startRedirectNavigation( + result, + redirectNavigation, + opts && opts.replace + ); + return { shortCircuited: true }; + } + + if (isErrorResult(result)) { + // Store off the pending error - we use it to determine which loaders + // to call and will commit it when we complete the navigation + let boundaryMatch = findNearestBoundary(matches, actionMatch.route.id); + + // By default, all submissions are REPLACE navigations, but if the + // action threw an error that'll be rendered in an errorElement, we fall + // back to PUSH so that the user can use the back button to get back to + // the pre-submission form location to try again + if ((opts && opts.replace) !== true) { + pendingAction = HistoryAction.Push; + } + + return { + pendingActionError: { [boundaryMatch.route.id]: result.error }, + }; + } + + if (isDeferredResult(result)) { + throw new Error("defer() is not supported in actions"); + } + + return { + pendingActionData: { [actionMatch.route.id]: result.data }, + }; + } + + // Call all applicable loaders for the given matches, handling redirects, + // errors, etc. + async function handleLoaders( + request: Request, + location: Location, + matches: AgnosticDataRouteMatch[], + overrideNavigation?: Navigation, + submission?: Submission, + replace?: boolean, + pendingActionData?: RouteData, + pendingError?: RouteData + ): Promise { + // Figure out the right navigation we want to use for data loading + let loadingNavigation = overrideNavigation; + if (!loadingNavigation) { + let navigation: NavigationStates["Loading"] = { + state: "loading", + location, + formMethod: undefined, + formAction: undefined, + formEncType: undefined, + formData: undefined, + }; + loadingNavigation = navigation; + } + + let [matchesToLoad, revalidatingFetchers] = getMatchesToLoad( + state, + matches, + submission, + location, + isRevalidationRequired, + cancelledDeferredRoutes, + cancelledFetcherLoads, + pendingActionData, + pendingError, + fetchLoadMatches + ); + + // Cancel pending deferreds for no-longer-matched routes or routes we're + // about to reload. Note that if this is an action reload we would have + // already cancelled all pending deferreds so this would be a no-op + cancelActiveDeferreds( + (routeId) => + !(matches && matches.some((m) => m.route.id === routeId)) || + (matchesToLoad && matchesToLoad.some((m) => m.route.id === routeId)) + ); + + // Short circuit if we have no loaders to run + if (matchesToLoad.length === 0 && revalidatingFetchers.length === 0) { + completeNavigation(location, { + matches, + loaderData: mergeLoaderData(state.loaderData, {}, matches), + // Commit pending error if we're short circuiting + errors: pendingError || null, + actionData: pendingActionData || null, + }); + return { shortCircuited: true }; + } + + // If this is an uninterrupted revalidation, we remain in our current idle + // state. If not, we need to switch to our loading state and load data, + // preserving any new action data or existing action data (in the case of + // a revalidation interrupting an actionReload) + if (!isUninterruptedRevalidation) { + revalidatingFetchers.forEach(([key]) => { + const fetcher = state.fetchers.get(key); + let revalidatingFetcher: FetcherStates["Loading"] = { + state: "loading", + data: fetcher && fetcher.data, + formMethod: undefined, + formAction: undefined, + formEncType: undefined, + formData: undefined, + }; + state.fetchers.set(key, revalidatingFetcher); + }); + updateState({ + navigation: loadingNavigation, + actionData: pendingActionData || state.actionData || null, + ...(revalidatingFetchers.length > 0 + ? { fetchers: new Map(state.fetchers) } + : {}), + }); + } + + pendingNavigationLoadId = ++incrementingLoadId; + revalidatingFetchers.forEach(([key]) => + fetchControllers.set(key, pendingNavigationController!) + ); + + let { results, loaderResults, fetcherResults } = + await callLoadersAndMaybeResolveData( + state.matches, + matchesToLoad, + revalidatingFetchers, + request + ); + + if (request.signal.aborted) { + return { shortCircuited: true }; + } + + // Clean up _after_ loaders have completed. Don't clean up if we short + // circuited because fetchControllers would have been aborted and + // reassigned to new controllers for the next navigation + revalidatingFetchers.forEach(([key]) => fetchControllers.delete(key)); + + // If any loaders returned a redirect Response, start a new REPLACE navigation + let redirect = findRedirect(results); + if (redirect) { + let redirectNavigation = getLoaderRedirect(state, redirect); + await startRedirectNavigation(redirect, redirectNavigation, replace); + return { shortCircuited: true }; + } + + // Process and commit output from loaders + let { loaderData, errors } = processLoaderData( + state, + matches, + matchesToLoad, + loaderResults, + pendingError, + revalidatingFetchers, + fetcherResults, + activeDeferreds + ); + + // Wire up subscribers to update loaderData as promises settle + activeDeferreds.forEach((deferredData, routeId) => { + deferredData.subscribe((aborted) => { + // Note: No need to updateState here since the TrackedPromise on + // loaderData is stable across resolve/reject + // Remove this instance if we were aborted or if promises have settled + if (aborted || deferredData.done) { + activeDeferreds.delete(routeId); + } + }); + }); + + markFetchRedirectsDone(); + let didAbortFetchLoads = abortStaleFetchLoads(pendingNavigationLoadId); + + return { + loaderData, + errors, + ...(didAbortFetchLoads || revalidatingFetchers.length > 0 + ? { fetchers: new Map(state.fetchers) } + : {}), + }; + } + + function getFetcher(key: string): Fetcher { + return state.fetchers.get(key) || IDLE_FETCHER; + } + + // Trigger a fetcher load/submit for the given fetcher key + function fetch( + key: string, + routeId: string, + href: string, + opts?: RouterFetchOptions + ) { + if (typeof AbortController === "undefined") { + throw new Error( + "router.fetch() was called during the server render, but it shouldn't be. " + + "You are likely calling a useFetcher() method in the body of your component. " + + "Try moving it to a useEffect or a callback." + ); + } + + if (fetchControllers.has(key)) abortFetcher(key); + + let matches = matchRoutes(dataRoutes, href, init.basename); + if (!matches) { + setFetcherError(key, routeId, new ErrorResponse(404, "Not Found", null)); + return; + } + + let { path, submission } = normalizeNavigateOptions(href, opts, true); + let match = getTargetMatch(matches, path); + + if (submission) { + handleFetcherAction(key, routeId, path, match, submission); + return; + } + + // Store off the match so we can call it's shouldRevalidate on subsequent + // revalidations + fetchLoadMatches.set(key, [path, match]); + handleFetcherLoader(key, routeId, path, match); + } + + // Call the action for the matched fetcher.submit(), and then handle redirects, + // errors, and revalidation + async function handleFetcherAction( + key: string, + routeId: string, + path: string, + match: AgnosticDataRouteMatch, + submission: Submission + ) { + interruptActiveLoads(); + fetchLoadMatches.delete(key); + + if (!match.route.action) { + let { error } = getMethodNotAllowedResult(path); + setFetcherError(key, routeId, error); + return; + } + + // Put this fetcher into it's submitting state + let existingFetcher = state.fetchers.get(key); + let fetcher: FetcherStates["Submitting"] = { + state: "submitting", + ...submission, + data: existingFetcher && existingFetcher.data, + }; + state.fetchers.set(key, fetcher); + updateState({ fetchers: new Map(state.fetchers) }); + + // Call the action for the fetcher + let abortController = new AbortController(); + let fetchRequest = createRequest(path, abortController.signal, submission); + fetchControllers.set(key, abortController); + + let actionResult = await callLoaderOrAction("action", fetchRequest, match); + + if (fetchRequest.signal.aborted) { + // We can delete this so long as we weren't aborted by ou our own fetcher + // re-submit which would have put _new_ controller is in fetchControllers + if (fetchControllers.get(key) === abortController) { + fetchControllers.delete(key); + } + return; + } + + if (isRedirectResult(actionResult)) { + fetchControllers.delete(key); + fetchRedirectIds.add(key); + let loadingFetcher: FetcherStates["Loading"] = { + state: "loading", + ...submission, + data: undefined, + }; + state.fetchers.set(key, loadingFetcher); + updateState({ fetchers: new Map(state.fetchers) }); + + let redirectNavigation: NavigationStates["Loading"] = { + state: "loading", + location: createLocation(state.location, actionResult.location), + ...submission, + }; + await startRedirectNavigation(actionResult, redirectNavigation); + return; + } + + // Process any non-redirect errors thrown + if (isErrorResult(actionResult)) { + setFetcherError(key, routeId, actionResult.error); + return; + } + + if (isDeferredResult(actionResult)) { + invariant(false, "defer() is not supported in actions"); + } + + // Start the data load for current matches, or the next location if we're + // in the middle of a navigation + let nextLocation = state.navigation.location || state.location; + let revalidationRequest = createRequest( + nextLocation, + abortController.signal + ); + let matches = + state.navigation.state !== "idle" + ? matchRoutes(dataRoutes, state.navigation.location, init.basename) + : state.matches; + + invariant(matches, "Didn't find any matches after fetcher action"); + + let loadId = ++incrementingLoadId; + fetchReloadIds.set(key, loadId); + + let loadFetcher: FetcherStates["Loading"] = { + state: "loading", + data: actionResult.data, + ...submission, + }; + state.fetchers.set(key, loadFetcher); + + let [matchesToLoad, revalidatingFetchers] = getMatchesToLoad( + state, + matches, + submission, + nextLocation, + isRevalidationRequired, + cancelledDeferredRoutes, + cancelledFetcherLoads, + { [match.route.id]: actionResult.data }, + undefined, // No need to send through errors since we short circuit above + fetchLoadMatches + ); + + // Put all revalidating fetchers into the loading state, except for the + // current fetcher which we want to keep in it's current loading state which + // contains it's action submission info + action data + revalidatingFetchers + .filter(([staleKey]) => staleKey !== key) + .forEach(([staleKey]) => { + let existingFetcher = state.fetchers.get(staleKey); + let revalidatingFetcher: FetcherStates["Loading"] = { + state: "loading", + data: existingFetcher && existingFetcher.data, + formMethod: undefined, + formAction: undefined, + formEncType: undefined, + formData: undefined, + }; + state.fetchers.set(staleKey, revalidatingFetcher); + fetchControllers.set(staleKey, abortController); + }); + + updateState({ fetchers: new Map(state.fetchers) }); + + let { results, loaderResults, fetcherResults } = + await callLoadersAndMaybeResolveData( + state.matches, + matchesToLoad, + revalidatingFetchers, + revalidationRequest + ); + + if (abortController.signal.aborted) { + return; + } + + fetchReloadIds.delete(key); + fetchControllers.delete(key); + revalidatingFetchers.forEach(([staleKey]) => + fetchControllers.delete(staleKey) + ); + + let redirect = findRedirect(results); + if (redirect) { + let redirectNavigation = getLoaderRedirect(state, redirect); + await startRedirectNavigation(redirect, redirectNavigation); + return; + } + + // Process and commit output from loaders + let { loaderData, errors } = processLoaderData( + state, + state.matches, + matchesToLoad, + loaderResults, + undefined, + revalidatingFetchers, + fetcherResults, + activeDeferreds + ); + + let doneFetcher: FetcherStates["Idle"] = { + state: "idle", + data: actionResult.data, + formMethod: undefined, + formAction: undefined, + formEncType: undefined, + formData: undefined, + }; + state.fetchers.set(key, doneFetcher); + + let didAbortFetchLoads = abortStaleFetchLoads(loadId); + + // If we are currently in a navigation loading state and this fetcher is + // more recent than the navigation, we want the newer data so abort the + // navigation and complete it with the fetcher data + if ( + state.navigation.state === "loading" && + loadId > pendingNavigationLoadId + ) { + invariant(pendingAction, "Expected pending action"); + pendingNavigationController && pendingNavigationController.abort(); + + completeNavigation(state.navigation.location, { + matches, + loaderData, + errors, + fetchers: new Map(state.fetchers), + }); + } else { + // otherwise just update with the fetcher data, preserving any existing + // loaderData for loaders that did not need to reload. We have to + // manually merge here since we aren't going through completeNavigation + updateState({ + errors, + loaderData: mergeLoaderData(state.loaderData, loaderData, matches), + ...(didAbortFetchLoads ? { fetchers: new Map(state.fetchers) } : {}), + }); + isRevalidationRequired = false; + } + } + + // Call the matched loader for fetcher.load(), handling redirects, errors, etc. + async function handleFetcherLoader( + key: string, + routeId: string, + path: string, + match: AgnosticDataRouteMatch + ) { + let existingFetcher = state.fetchers.get(key); + // Put this fetcher into it's loading state + let loadingFetcher: FetcherStates["Loading"] = { + state: "loading", + formMethod: undefined, + formAction: undefined, + formEncType: undefined, + formData: undefined, + data: existingFetcher && existingFetcher.data, + }; + state.fetchers.set(key, loadingFetcher); + updateState({ fetchers: new Map(state.fetchers) }); + + // Call the loader for this fetcher route match + let abortController = new AbortController(); + let fetchRequest = createRequest(path, abortController.signal); + fetchControllers.set(key, abortController); + let result: DataResult = await callLoaderOrAction( + "loader", + fetchRequest, + match + ); + + // Deferred isn't supported or fetcher loads, await everything and treat it + // as a normal load. resolveDeferredData will return undefined if this + // fetcher gets aborted, so we just leave result untouched and short circuit + // below if that happens + if (isDeferredResult(result)) { + result = + (await resolveDeferredData(result, fetchRequest.signal, true)) || + result; + } + + // We can delete this so long as we weren't aborted by ou our own fetcher + // re-load which would have put _new_ controller is in fetchControllers + if (fetchControllers.get(key) === abortController) { + fetchControllers.delete(key); + } + + if (fetchRequest.signal.aborted) { + return; + } + + // If the loader threw a redirect Response, start a new REPLACE navigation + if (isRedirectResult(result)) { + let redirectNavigation = getLoaderRedirect(state, result); + await startRedirectNavigation(result, redirectNavigation); + return; + } + + // Process any non-redirect errors thrown + if (isErrorResult(result)) { + let boundaryMatch = findNearestBoundary(state.matches, routeId); + state.fetchers.delete(key); + // TODO: In remix, this would reset to IDLE_NAVIGATION if it was a catch - + // do we need to behave any differently with our non-redirect errors? + // What if it was a non-redirect Response? + updateState({ + fetchers: new Map(state.fetchers), + errors: { + [boundaryMatch.route.id]: result.error, + }, + }); + return; + } + + invariant(!isDeferredResult(result), "Unhandled fetcher deferred data"); + + // Put the fetcher back into an idle state + let doneFetcher: FetcherStates["Idle"] = { + state: "idle", + data: result.data, + formMethod: undefined, + formAction: undefined, + formEncType: undefined, + formData: undefined, + }; + state.fetchers.set(key, doneFetcher); + updateState({ fetchers: new Map(state.fetchers) }); + } + + /** + * Utility function to handle redirects returned from an action or loader. + * Normally, a redirect "replaces" the navigation that triggered it. So, for + * example: + * + * - user is on /a + * - user clicks a link to /b + * - loader for /b redirects to /c + * + * In a non-JS app the browser would track the in-flight navigation to /b and + * then replace it with /c when it encountered the redirect response. In + * the end it would only ever update the URL bar with /c. + * + * In client-side routing using pushState/replaceState, we aim to emulate + * this behavior and we also do not update history until the end of the + * navigation (including processed redirects). This means that we never + * actually touch history until we've processed redirects, so we just use + * the history action from the original navigation (PUSH or REPLACE). + */ + async function startRedirectNavigation( + redirect: RedirectResult, + navigation: Navigation, + replace?: boolean + ) { + if (redirect.revalidate) { + isRevalidationRequired = true; + } + invariant( + navigation.location, + "Expected a location on the redirect navigation" + ); + // There's no need to abort on redirects, since we don't detect the + // redirect until the action/loaders have settled + pendingNavigationController = null; + + let redirectHistoryAction = + replace === true ? HistoryAction.Replace : HistoryAction.Push; + await startNavigation(redirectHistoryAction, navigation.location, { + overrideNavigation: navigation, + }); + } + + async function callLoadersAndMaybeResolveData( + currentMatches: AgnosticDataRouteMatch[], + matchesToLoad: AgnosticDataRouteMatch[], + fetchersToLoad: RevalidatingFetcher[], + request: Request + ) { + // Call all navigation loaders and revalidating fetcher loaders in parallel, + // then slice off the results into separate arrays so we can handle them + // accordingly + let results = await Promise.all([ + ...matchesToLoad.map((m) => callLoaderOrAction("loader", request, m)), + ...fetchersToLoad.map(([, href, match]) => + callLoaderOrAction("loader", createRequest(href, request.signal), match) + ), + ]); + let loaderResults = results.slice(0, matchesToLoad.length); + let fetcherResults = results.slice(matchesToLoad.length); + + await Promise.all([ + resolveDeferredResults( + currentMatches, + matchesToLoad, + loaderResults, + request.signal, + false, + state.loaderData + ), + resolveDeferredResults( + currentMatches, + fetchersToLoad.map(([, , match]) => match), + fetcherResults, + request.signal, + true + ), + ]); + + return { results, loaderResults, fetcherResults }; + } + + function interruptActiveLoads() { + // Every interruption triggers a revalidation + isRevalidationRequired = true; + + // Cancel pending route-level deferreds and mark cancelled routes for + // revalidation + cancelledDeferredRoutes.push(...cancelActiveDeferreds()); + + // Abort in-flight fetcher loads + fetchLoadMatches.forEach((_, key) => { + if (fetchControllers.has(key)) { + cancelledFetcherLoads.push(key); + abortFetcher(key); + } + }); + } + + function setFetcherError(key: string, routeId: string, error: any) { + let boundaryMatch = findNearestBoundary(state.matches, routeId); + deleteFetcher(key); + updateState({ + errors: { + [boundaryMatch.route.id]: error, + }, + fetchers: new Map(state.fetchers), + }); + } + + function deleteFetcher(key: string): void { + if (fetchControllers.has(key)) abortFetcher(key); + fetchLoadMatches.delete(key); + fetchReloadIds.delete(key); + fetchRedirectIds.delete(key); + state.fetchers.delete(key); + } + + function abortFetcher(key: string) { + let controller = fetchControllers.get(key); + invariant(controller, `Expected fetch controller: ${key}`); + controller.abort(); + fetchControllers.delete(key); + } + + function markFetchersDone(keys: string[]) { + for (let key of keys) { + let fetcher = getFetcher(key); + let doneFetcher: FetcherStates["Idle"] = { + state: "idle", + data: fetcher.data, + formMethod: undefined, + formAction: undefined, + formEncType: undefined, + formData: undefined, + }; + state.fetchers.set(key, doneFetcher); + } + } + + function markFetchRedirectsDone(): void { + let doneKeys = []; + for (let key of fetchRedirectIds) { + let fetcher = state.fetchers.get(key); + invariant(fetcher, `Expected fetcher: ${key}`); + if (fetcher.state === "loading") { + fetchRedirectIds.delete(key); + doneKeys.push(key); + } + } + markFetchersDone(doneKeys); + } + + function abortStaleFetchLoads(landedId: number): boolean { + let yeetedKeys = []; + for (let [key, id] of fetchReloadIds) { + if (id < landedId) { + let fetcher = state.fetchers.get(key); + invariant(fetcher, `Expected fetcher: ${key}`); + if (fetcher.state === "loading") { + abortFetcher(key); + fetchReloadIds.delete(key); + yeetedKeys.push(key); + } + } + } + markFetchersDone(yeetedKeys); + return yeetedKeys.length > 0; + } + + function cancelActiveDeferreds( + predicate?: (routeId: string) => boolean + ): string[] { + let cancelledRouteIds: string[] = []; + activeDeferreds.forEach((dfd, routeId) => { + if (!predicate || predicate(routeId)) { + // Cancel the deferred - but do not remove from activeDeferreds here - + // we rely on the subscribers to do that so our tests can assert proper + // cleanup via _internalActiveDeferreds + dfd.cancel(); + cancelledRouteIds.push(routeId); + activeDeferreds.delete(routeId); + } + }); + return cancelledRouteIds; + } + + // Opt in to capturing and reporting scroll positions during navigations, + // used by the component + function enableScrollRestoration( + positions: Record, + getPosition: GetScrollPositionFunction, + getKey?: GetScrollRestorationKeyFunction + ) { + savedScrollPositions = positions; + getScrollPosition = getPosition; + getScrollRestorationKey = getKey || ((location) => location.key); + + // Perform initial hydration scroll restoration, since we miss the boat on + // the initial updateState() because we've not yet rendered + // and therefore have no savedScrollPositions available + if (!initialScrollRestored && state.navigation === IDLE_NAVIGATION) { + initialScrollRestored = true; + let y = getSavedScrollPosition(state.location, state.matches); + if (y != null) { + updateState({ restoreScrollPosition: y }); + } + } + + return () => { + savedScrollPositions = null; + getScrollPosition = null; + getScrollRestorationKey = null; + }; + } + + function saveScrollPosition( + location: Location, + matches: AgnosticDataRouteMatch[] + ): void { + if (savedScrollPositions && getScrollRestorationKey && getScrollPosition) { + let userMatches = matches.map((m) => + createUseMatchesMatch(m, state.loaderData) + ); + let key = getScrollRestorationKey(location, userMatches) || location.key; + savedScrollPositions[key] = getScrollPosition(); + } + } + + function getSavedScrollPosition( + location: Location, + matches: AgnosticDataRouteMatch[] + ): number | null { + if (savedScrollPositions && getScrollRestorationKey && getScrollPosition) { + let userMatches = matches.map((m) => + createUseMatchesMatch(m, state.loaderData) + ); + let key = getScrollRestorationKey(location, userMatches) || location.key; + let y = savedScrollPositions[key]; + if (typeof y === "number") { + return y; + } + } + return null; + } + + router = { + get basename() { + return init.basename; + }, + get state() { + return state; + }, + get routes() { + return dataRoutes; + }, + initialize, + subscribe, + enableScrollRestoration, + navigate, + fetch, + revalidate, + createHref, + getFetcher, + deleteFetcher, + dispose, + _internalFetchControllers: fetchControllers, + _internalActiveDeferreds: activeDeferreds, + }; + + return router; +} +//#endregion + +//////////////////////////////////////////////////////////////////////////////// +//#region createStaticHandler +//////////////////////////////////////////////////////////////////////////////// + +export function unstable_createStaticHandler( + routes: AgnosticRouteObject[] +): StaticHandler { + invariant( + routes.length > 0, + "You must provide a non-empty routes array to unstable_createStaticHandler" + ); + + let dataRoutes = convertRoutesToDataRoutes(routes); + + async function query( + request: Request + ): Promise { + let { location, result } = await queryImpl(request); + if (result instanceof Response) { + return result; + } + // When returning StaticHandlerContext, we patch back in the location here + // since we need it for React Context. But this helps keep our submit and + // loadRouteData operating on a Request instead of a Location + return { location, ...result }; + } + + async function queryRoute(request: Request, routeId: string): Promise { + let { result } = await queryImpl(request, routeId); + if (result instanceof Response) { + return result; + } + + let error = result.errors ? Object.values(result.errors)[0] : undefined; + if (error !== undefined) { + // While we always re-throw Responses returned from loaders/actions + // directly for route requests and prevent the unwrapping into an + // ErrorResponse, we still need this for error cases _prior_ the + // execution of the loader/action, such as a 404/405 error. + if (isRouteErrorResponse(error)) { + return new Response(error.data, { + status: error.status, + statusText: error.statusText, + }); + } + // If we got back result.errors, that means the loader/action threw + // _something_ that wasn't a Response, but it's not guaranteed/required + // to be an `instanceof Error` either, so we have to use throw here to + // preserve the "error" state outside of queryImpl. + throw error; + } + + // Pick off the right state value to return + let routeData = [result.actionData, result.loaderData].find((v) => v); + let value = Object.values(routeData || {})[0]; + + if (isRouteErrorResponse(value)) { + return new Response(value.data, { + status: value.status, + statusText: value.statusText, + }); + } + + return value; + } + + async function queryImpl( + request: Request, + routeId?: string + ): Promise<{ + location: Location; + result: Omit | Response; + }> { + invariant( + request.method !== "HEAD", + "query()/queryRoute() do not support HEAD requests" + ); + invariant( + request.signal, + "query()/queryRoute() requests must contain an AbortController signal" + ); + + let { location, matches, shortCircuitState } = matchRequest( + request, + routeId + ); + + try { + if (shortCircuitState) { + return { location, result: shortCircuitState }; + } + + if (request.method !== "GET") { + let result = await submit( + request, + matches, + getTargetMatch(matches, location), + routeId != null + ); + return { location, result }; + } + + let result = await loadRouteData(request, matches, routeId != null); + return { + location, + result: { + ...result, + actionData: null, + actionHeaders: {}, + }, + }; + } catch (e) { + if (e instanceof Response) { + return { location, result: e }; + } + throw e; + } + } + + async function submit( + request: Request, + matches: AgnosticDataRouteMatch[], + actionMatch: AgnosticDataRouteMatch, + isRouteRequest: boolean + ): Promise | Response> { + let result: DataResult; + if (!actionMatch.route.action) { + let href = createHref(new URL(request.url)); + result = getMethodNotAllowedResult(href); + } else { + result = await callLoaderOrAction( + "action", + request, + actionMatch, + true, + isRouteRequest + ); + + if (request.signal.aborted) { + let method = isRouteRequest ? "queryRoute" : "query"; + throw new Error(`${method}() call aborted`); + } + } + + if (isRedirectResult(result)) { + // Uhhhh - this should never happen, we should always throw these from + // calLoaderOrAction, but the type narrowing here keeps TS happy and we + // can get back on the "throw all redirect responses" train here should + // this ever happen :/ + throw new Response(null, { + status: result.status, + headers: { + Location: result.location, + }, + }); + } + + if (isDeferredResult(result)) { + throw new Error("defer() is not supported in actions"); + } + + if (isRouteRequest) { + if (isErrorResult(result)) { + let boundaryMatch = findNearestBoundary(matches, actionMatch.route.id); + return { + matches: [actionMatch], + loaderData: {}, + actionData: null, + errors: { + [boundaryMatch.route.id]: result.error, + }, + // Note: statusCode + headers are unused here since queryRoute will + // return the raw Response or value + statusCode: 500, + loaderHeaders: {}, + actionHeaders: {}, + }; + } + + return { + matches: [actionMatch], + loaderData: {}, + actionData: { [actionMatch.route.id]: result.data }, + errors: null, + // Note: statusCode + headers are unused here since queryRoute will + // return the raw Response or value + statusCode: 200, + loaderHeaders: {}, + actionHeaders: {}, + }; + } + + if (isErrorResult(result)) { + // Store off the pending error - we use it to determine which loaders + // to call and will commit it when we complete the navigation + let boundaryMatch = findNearestBoundary(matches, actionMatch.route.id); + let context = await loadRouteData(request, matches, isRouteRequest, { + [boundaryMatch.route.id]: result.error, + }); + + // action status codes take precedence over loader status codes + return { + ...context, + statusCode: isRouteErrorResponse(result.error) + ? result.error.status + : 500, + actionData: null, + actionHeaders: { + ...(result.headers ? { [actionMatch.route.id]: result.headers } : {}), + }, + }; + } + + let context = await loadRouteData(request, matches, isRouteRequest); + + return { + ...context, + // action status codes take precedence over loader status codes + ...(result.statusCode ? { statusCode: result.statusCode } : {}), + actionData: { + [actionMatch.route.id]: result.data, + }, + actionHeaders: { + ...(result.headers ? { [actionMatch.route.id]: result.headers } : {}), + }, + }; + } + + async function loadRouteData( + request: Request, + matches: AgnosticDataRouteMatch[], + isRouteRequest: boolean, + pendingActionError?: RouteData + ): Promise< + | Omit + | Response + > { + let matchesToLoad = getLoaderMatchesUntilBoundary( + matches, + Object.keys(pendingActionError || {})[0] + ).filter((m) => m.route.loader); + + // Short circuit if we have no loaders to run + if (matchesToLoad.length === 0) { + return { + matches, + loaderData: {}, + errors: pendingActionError || null, + statusCode: 200, + loaderHeaders: {}, + }; + } + + let results = await Promise.all([ + ...matchesToLoad.map((m) => + callLoaderOrAction("loader", request, m, true, isRouteRequest) + ), + ]); + + if (request.signal.aborted) { + let method = isRouteRequest ? "queryRoute" : "query"; + throw new Error(`${method}() call aborted`); + } + + // Can't do anything with these without the Remix side of things, so just + // cancel them for now + results.forEach((result) => { + if (isDeferredResult(result)) { + result.deferredData.cancel(); + } + }); + + // Process and commit output from loaders + let context = processRouteLoaderData( + matches, + matchesToLoad, + results, + pendingActionError + ); + + return { + ...context, + matches, + }; + } + + function matchRequest( + req: Request, + routeId?: string + ): { + location: Location; + matches: AgnosticDataRouteMatch[]; + routeMatch?: AgnosticDataRouteMatch; + shortCircuitState?: Omit; + } { + let url = new URL(req.url); + let location = createLocation("", createPath(url), null, "default"); + let matches = matchRoutes(dataRoutes, location); + if (matches && routeId) { + matches = matches.filter((m) => m.route.id === routeId); + } + + // Short circuit with a 404 if we match nothing + if (!matches) { + let { + matches: notFoundMatches, + route, + error, + } = getNotFoundMatches(dataRoutes); + return { + location, + matches: notFoundMatches, + shortCircuitState: { + matches: notFoundMatches, + loaderData: {}, + actionData: null, + errors: { + [route.id]: error, + }, + statusCode: 404, + loaderHeaders: {}, + actionHeaders: {}, + }, + }; + } + + return { location, matches }; + } + + return { + dataRoutes, + query, + queryRoute, + }; +} + +//#endregion + +//////////////////////////////////////////////////////////////////////////////// +//#region Helpers +//////////////////////////////////////////////////////////////////////////////// + +/** + * Given an existing StaticHandlerContext and an error thrown at render time, + * provide an updated StaticHandlerContext suitable for a second SSR render + */ +export function getStaticContextFromError( + routes: AgnosticDataRouteObject[], + context: StaticHandlerContext, + error: any +) { + let newContext: StaticHandlerContext = { + ...context, + statusCode: 500, + errors: { + [context._deepestRenderedBoundaryId || routes[0].id]: error, + }, + }; + return newContext; +} + +// Normalize navigation options by converting formMethod=GET formData objects to +// URLSearchParams so they behave identically to links with query params +function normalizeNavigateOptions( + to: To, + opts?: RouterNavigateOptions, + isFetcher = false +): { + path: string; + submission?: Submission; + error?: ErrorResponse; +} { + let path = typeof to === "string" ? to : createPath(to); + + // Return location verbatim on non-submission navigations + if (!opts || (!("formMethod" in opts) && !("formData" in opts))) { + return { path }; + } + + // Create a Submission on non-GET navigations + if (opts.formMethod != null && opts.formMethod !== "get") { + return { + path, + submission: { + formMethod: opts.formMethod, + formAction: createHref(parsePath(path)), + formEncType: + (opts && opts.formEncType) || "application/x-www-form-urlencoded", + formData: opts.formData, + }, + }; + } + + // No formData to flatten for GET submission + if (!opts.formData) { + return { path }; + } + + // Flatten submission onto URLSearchParams for GET submissions + let parsedPath = parsePath(path); + try { + let searchParams = convertFormDataToSearchParams(opts.formData); + // Since fetcher GET submissions only run a single loader (as opposed to + // navigation GET submissions which run all loaders), we need to preserve + // any incoming ?index params + if ( + isFetcher && + parsedPath.search && + hasNakedIndexQuery(parsedPath.search) + ) { + searchParams.append("index", ""); + } + parsedPath.search = `?${searchParams}`; + } catch (e) { + return { + path, + error: new ErrorResponse( + 400, + "Bad Request", + "Cannot submit binary form data using GET" + ), + }; + } + + return { path: createPath(parsedPath) }; +} + +function getLoaderRedirect( + state: RouterState, + redirect: RedirectResult +): Navigation { + let { formMethod, formAction, formEncType, formData } = state.navigation; + let navigation: NavigationStates["Loading"] = { + state: "loading", + location: createLocation(state.location, redirect.location), + formMethod: formMethod || undefined, + formAction: formAction || undefined, + formEncType: formEncType || undefined, + formData: formData || undefined, + }; + return navigation; +} + +// Filter out all routes below any caught error as they aren't going to +// render so we don't need to load them +function getLoaderMatchesUntilBoundary( + matches: AgnosticDataRouteMatch[], + boundaryId?: string +) { + let boundaryMatches = matches; + if (boundaryId) { + let index = matches.findIndex((m) => m.route.id === boundaryId); + if (index >= 0) { + boundaryMatches = matches.slice(0, index); + } + } + return boundaryMatches; +} + +function getMatchesToLoad( + state: RouterState, + matches: AgnosticDataRouteMatch[], + submission: Submission | undefined, + location: Location, + isRevalidationRequired: boolean, + cancelledDeferredRoutes: string[], + cancelledFetcherLoads: string[], + pendingActionData?: RouteData, + pendingError?: RouteData, + fetchLoadMatches?: Map +): [AgnosticDataRouteMatch[], RevalidatingFetcher[]] { + let actionResult = pendingError + ? Object.values(pendingError)[0] + : pendingActionData + ? Object.values(pendingActionData)[0] + : null; + + // Pick navigation matches that are net-new or qualify for revalidation + let boundaryId = pendingError ? Object.keys(pendingError)[0] : undefined; + let boundaryMatches = getLoaderMatchesUntilBoundary(matches, boundaryId); + let navigationMatches = boundaryMatches.filter( + (match, index) => + match.route.loader != null && + (isNewLoader(state.loaderData, state.matches[index], match) || + // If this route had a pending deferred cancelled it must be revalidated + cancelledDeferredRoutes.some((id) => id === match.route.id) || + shouldRevalidateLoader( + state.location, + state.matches[index], + submission, + location, + match, + isRevalidationRequired, + actionResult + )) + ); + + // Pick fetcher.loads that need to be revalidated + let revalidatingFetchers: RevalidatingFetcher[] = []; + fetchLoadMatches && + fetchLoadMatches.forEach(([href, match], key) => { + // This fetcher was cancelled from a prior action submission - force reload + if (cancelledFetcherLoads.includes(key)) { + revalidatingFetchers.push([key, href, match]); + } else if (isRevalidationRequired) { + let shouldRevalidate = shouldRevalidateLoader( + href, + match, + submission, + href, + match, + isRevalidationRequired, + actionResult + ); + if (shouldRevalidate) { + revalidatingFetchers.push([key, href, match]); + } + } + }); + + return [navigationMatches, revalidatingFetchers]; +} + +function isNewLoader( + currentLoaderData: RouteData, + currentMatch: AgnosticDataRouteMatch, + match: AgnosticDataRouteMatch +) { + let isNew = + // [a] -> [a, b] + !currentMatch || + // [a, b] -> [a, c] + match.route.id !== currentMatch.route.id; + + // Handle the case that we don't have data for a re-used route, potentially + // from a prior error or from a cancelled pending deferred + let isMissingData = currentLoaderData[match.route.id] === undefined; + + // Always load if this is a net-new route or we don't yet have data + return isNew || isMissingData; +} + +function isNewRouteInstance( + currentMatch: AgnosticDataRouteMatch, + match: AgnosticDataRouteMatch +) { + let currentPath = currentMatch.route.path; + return ( + // param change for this match, /users/123 -> /users/456 + currentMatch.pathname !== match.pathname || + // splat param changed, which is not present in match.path + // e.g. /files/images/avatar.jpg -> files/finances.xls + (currentPath && + currentPath.endsWith("*") && + currentMatch.params["*"] !== match.params["*"]) + ); +} + +function shouldRevalidateLoader( + currentLocation: string | Location, + currentMatch: AgnosticDataRouteMatch, + submission: Submission | undefined, + location: string | Location, + match: AgnosticDataRouteMatch, + isRevalidationRequired: boolean, + actionResult: DataResult | undefined +) { + let currentUrl = createURL(currentLocation); + let currentParams = currentMatch.params; + let nextUrl = createURL(location); + let nextParams = match.params; + + // This is the default implementation as to when we revalidate. If the route + // provides it's own implementation, then we give them full control but + // provide this value so they can leverage it if needed after they check + // their own specific use cases + // Note that fetchers always provide the same current/next locations so the + // URL-based checks here don't apply to fetcher shouldRevalidate calls + let defaultShouldRevalidate = + isNewRouteInstance(currentMatch, match) || + // Clicked the same link, resubmitted a GET form + currentUrl.toString() === nextUrl.toString() || + // Search params affect all loaders + currentUrl.search !== nextUrl.search || + // Forced revalidation due to submission, useRevalidate, or X-Remix-Revalidate + isRevalidationRequired; + + if (match.route.shouldRevalidate) { + let routeChoice = match.route.shouldRevalidate({ + currentUrl, + currentParams, + nextUrl, + nextParams, + ...submission, + actionResult, + defaultShouldRevalidate, + }); + if (typeof routeChoice === "boolean") { + return routeChoice; + } + } + + return defaultShouldRevalidate; +} + +async function callLoaderOrAction( + type: "loader" | "action", + request: Request, + match: AgnosticDataRouteMatch, + skipRedirects: boolean = false, + isRouteRequest: boolean = false +): Promise { + let resultType; + let result; + + // Setup a promise we can race against so that abort signals short circuit + let reject: () => void; + let abortPromise = new Promise((_, r) => (reject = r)); + let onReject = () => reject(); + request.signal.addEventListener("abort", onReject); + + try { + let handler = match.route[type]; + invariant( + handler, + `Could not find the ${type} to run on the "${match.route.id}" route` + ); + + result = await Promise.race([ + handler({ request, params: match.params }), + abortPromise, + ]); + } catch (e) { + resultType = ResultType.error; + result = e; + } finally { + request.signal.removeEventListener("abort", onReject); + } + + if (result instanceof Response) { + // Process redirects + let status = result.status; + let location = result.headers.get("Location"); + + // For SSR single-route requests, we want to hand Responses back directly + // without unwrapping + if (isRouteRequest) { + throw result; + } + + if (status >= 300 && status <= 399 && location != null) { + // Don't process redirects in the router during SSR document requests. + // Instead, throw the Response and let the server handle it with an HTTP + // redirect + if (skipRedirects) { + throw result; + } + return { + type: ResultType.redirect, + status, + location, + revalidate: result.headers.get("X-Remix-Revalidate") !== null, + }; + } + + let data: any; + let contentType = result.headers.get("Content-Type"); + if (contentType && contentType.startsWith("application/json")) { + data = await result.json(); + } else { + data = await result.text(); + } + + if (resultType === ResultType.error) { + return { + type: resultType, + error: new ErrorResponse(status, result.statusText, data), + headers: result.headers, + }; + } + + return { + type: ResultType.data, + data, + statusCode: result.status, + headers: result.headers, + }; + } + + if (resultType === ResultType.error) { + return { type: resultType, error: result }; + } + + if (result instanceof DeferredData) { + return { type: ResultType.deferred, deferredData: result }; + } + + return { type: ResultType.data, data: result }; +} + +function createRequest( + location: string | Location, + signal: AbortSignal, + submission?: Submission +): Request { + let url = createURL(location).toString(); + let init: RequestInit = { signal }; + + if (submission) { + let { formMethod, formEncType, formData } = submission; + init.method = formMethod.toUpperCase(); + init.body = + formEncType === "application/x-www-form-urlencoded" + ? convertFormDataToSearchParams(formData) + : formData; + } + + // Content-Type is inferred (https://fetch.spec.whatwg.org/#dom-request) + return new Request(url, init); +} + +function convertFormDataToSearchParams(formData: FormData): URLSearchParams { + let searchParams = new URLSearchParams(); + + for (let [key, value] of formData.entries()) { + invariant( + typeof value === "string", + 'File inputs are not supported with encType "application/x-www-form-urlencoded", ' + + 'please use "multipart/form-data" instead.' + ); + searchParams.append(key, value); + } + + return searchParams; +} + +function processRouteLoaderData( + matches: AgnosticDataRouteMatch[], + matchesToLoad: AgnosticDataRouteMatch[], + results: DataResult[], + pendingError: RouteData | undefined, + activeDeferreds?: Map +): { + loaderData: RouterState["loaderData"]; + errors: RouterState["errors"] | null; + statusCode: number; + loaderHeaders: Record; +} { + // Fill in loaderData/errors from our loaders + let loaderData: RouterState["loaderData"] = {}; + let errors: RouterState["errors"] | null = null; + let statusCode: number | undefined; + let foundError = false; + let loaderHeaders: Record = {}; + + // Process loader results into state.loaderData/state.errors + results.forEach((result, index) => { + let id = matchesToLoad[index].route.id; + invariant( + !isRedirectResult(result), + "Cannot handle redirect results in processLoaderData" + ); + if (isErrorResult(result)) { + // Look upwards from the matched route for the closest ancestor + // error boundary, defaulting to the root match + let boundaryMatch = findNearestBoundary(matches, id); + let error = result.error; + // If we have a pending action error, we report it at the highest-route + // that throws a loader error, and then clear it out to indicate that + // it was consumed + if (pendingError) { + error = Object.values(pendingError)[0]; + pendingError = undefined; + } + errors = Object.assign(errors || {}, { + [boundaryMatch.route.id]: error, + }); + // Once we find our first (highest) error, we set the status code and + // prevent deeper status codes from overriding + if (!foundError) { + foundError = true; + statusCode = isRouteErrorResponse(result.error) + ? result.error.status + : 500; + } + if (result.headers) { + loaderHeaders[id] = result.headers; + } + } else if (isDeferredResult(result)) { + activeDeferreds && activeDeferreds.set(id, result.deferredData); + loaderData[id] = result.deferredData.data; + // TODO: Add statusCode/headers once we wire up streaming in Remix + } else { + loaderData[id] = result.data; + // Error status codes always override success status codes, but if all + // loaders are successful we take the deepest status code. + if ( + result.statusCode != null && + result.statusCode !== 200 && + !foundError + ) { + statusCode = result.statusCode; + } + if (result.headers) { + loaderHeaders[id] = result.headers; + } + } + }); + + // If we didn't consume the pending action error (i.e., all loaders + // resolved), then consume it here + if (pendingError) { + errors = pendingError; + } + + return { + loaderData, + errors, + statusCode: statusCode || 200, + loaderHeaders, + }; +} + +function processLoaderData( + state: RouterState, + matches: AgnosticDataRouteMatch[], + matchesToLoad: AgnosticDataRouteMatch[], + results: DataResult[], + pendingError: RouteData | undefined, + revalidatingFetchers: RevalidatingFetcher[], + fetcherResults: DataResult[], + activeDeferreds: Map +): { + loaderData: RouterState["loaderData"]; + errors?: RouterState["errors"]; +} { + let { loaderData, errors } = processRouteLoaderData( + matches, + matchesToLoad, + results, + pendingError, + activeDeferreds + ); + + // Process results from our revalidating fetchers + for (let index = 0; index < revalidatingFetchers.length; index++) { + let [key, , match] = revalidatingFetchers[index]; + invariant( + fetcherResults !== undefined && fetcherResults[index] !== undefined, + "Did not find corresponding fetcher result" + ); + let result = fetcherResults[index]; + + // Process fetcher non-redirect errors + if (isErrorResult(result)) { + let boundaryMatch = findNearestBoundary(state.matches, match.route.id); + if (!(errors && errors[boundaryMatch.route.id])) { + errors = { + ...errors, + [boundaryMatch.route.id]: result.error, + }; + } + state.fetchers.delete(key); + } else if (isRedirectResult(result)) { + // Should never get here, redirects should get processed above, but we + // keep this to type narrow to a success result in the else + throw new Error("Unhandled fetcher revalidation redirect"); + } else if (isDeferredResult(result)) { + // Should never get here, deferred data should be awaited for fetchers + // in resolveDeferredResults + throw new Error("Unhandled fetcher deferred data"); + } else { + let doneFetcher: FetcherStates["Idle"] = { + state: "idle", + data: result.data, + formMethod: undefined, + formAction: undefined, + formEncType: undefined, + formData: undefined, + }; + state.fetchers.set(key, doneFetcher); + } + } + + return { loaderData, errors }; +} + +function mergeLoaderData( + loaderData: RouteData, + newLoaderData: RouteData, + matches: AgnosticDataRouteMatch[] +): RouteData { + let mergedLoaderData = { ...newLoaderData }; + matches.forEach((match) => { + let id = match.route.id; + if (newLoaderData[id] === undefined && loaderData[id] !== undefined) { + mergedLoaderData[id] = loaderData[id]; + } + }); + return mergedLoaderData; +} + +// Find the nearest error boundary, looking upwards from the leaf route (or the +// route specified by routeId) for the closest ancestor error boundary, +// defaulting to the root match +function findNearestBoundary( + matches: AgnosticDataRouteMatch[], + routeId?: string +): AgnosticDataRouteMatch { + let eligibleMatches = routeId + ? matches.slice(0, matches.findIndex((m) => m.route.id === routeId) + 1) + : [...matches]; + return ( + eligibleMatches.reverse().find((m) => m.route.hasErrorBoundary === true) || + matches[0] + ); +} + +function getNotFoundMatches(routes: AgnosticDataRouteObject[]): { + matches: AgnosticDataRouteMatch[]; + route: AgnosticDataRouteObject; + error: ErrorResponse; +} { + // Prefer a root layout route if present, otherwise shim in a route object + let route = routes.find( + (r) => r.index || r.path === "" || r.path === "/" + ) || { + id: "__shim-404-route__", + }; + + return { + matches: [ + { + params: {}, + pathname: "", + pathnameBase: "", + route, + }, + ], + route, + error: new ErrorResponse(404, "Not Found", null), + }; +} + +function getMethodNotAllowedResult(path: Location | string): ErrorResult { + let href = typeof path === "string" ? path : createHref(path); + console.warn( + "You're trying to submit to a route that does not have an action. To " + + "fix this, please add an `action` function to the route for " + + `[${href}]` + ); + return { + type: ResultType.error, + error: new ErrorResponse( + 405, + "Method Not Allowed", + `No action found for [${href}]` + ), + }; +} + +// Find any returned redirect errors, starting from the lowest match +function findRedirect(results: DataResult[]): RedirectResult | undefined { + for (let i = results.length - 1; i >= 0; i--) { + let result = results[i]; + if (isRedirectResult(result)) { + return result; + } + } +} + +// Create an href to represent a "server" URL without the hash +function createHref(location: Partial | Location | URL) { + return (location.pathname || "") + (location.search || ""); +} + +function isHashChangeOnly(a: Location, b: Location): boolean { + return ( + a.pathname === b.pathname && a.search === b.search && a.hash !== b.hash + ); +} + +function isDeferredResult(result: DataResult): result is DeferredResult { + return result.type === ResultType.deferred; +} + +function isErrorResult(result: DataResult): result is ErrorResult { + return result.type === ResultType.error; +} + +function isRedirectResult(result?: DataResult): result is RedirectResult { + return (result && result.type) === ResultType.redirect; +} + +async function resolveDeferredResults( + currentMatches: AgnosticDataRouteMatch[], + matchesToLoad: AgnosticDataRouteMatch[], + results: DataResult[], + signal: AbortSignal, + isFetcher: boolean, + currentLoaderData?: RouteData +) { + for (let index = 0; index < results.length; index++) { + let result = results[index]; + let match = matchesToLoad[index]; + let currentMatch = currentMatches.find( + (m) => m.route.id === match.route.id + ); + let isRevalidatingLoader = + currentMatch != null && + !isNewRouteInstance(currentMatch, match) && + (currentLoaderData && currentLoaderData[match.route.id]) !== undefined; + + if (isDeferredResult(result) && (isFetcher || isRevalidatingLoader)) { + // Note: we do not have to touch activeDeferreds here since we race them + // against the signal in resolveDeferredData and they'll get aborted + // there if needed + await resolveDeferredData(result, signal, isFetcher).then((result) => { + if (result) { + results[index] = result || results[index]; + } + }); + } + } +} + +async function resolveDeferredData( + result: DeferredResult, + signal: AbortSignal, + unwrap = false +): Promise { + let aborted = await result.deferredData.resolveData(signal); + if (aborted) { + return; + } + + if (unwrap) { + try { + return { + type: ResultType.data, + data: result.deferredData.unwrappedData, + }; + } catch (e) { + // Handle any TrackedPromise._error values encountered while unwrapping + return { + type: ResultType.error, + error: e, + }; + } + } + + return { + type: ResultType.data, + data: result.deferredData.data, + }; +} + +function hasNakedIndexQuery(search: string): boolean { + return new URLSearchParams(search).getAll("index").some((v) => v === ""); +} + +// Note: This should match the format exported by useMatches, so if you change +// this please also change that :) Eventually we'll DRY this up +function createUseMatchesMatch( + match: AgnosticDataRouteMatch, + loaderData: RouteData +): UseMatchesMatch { + let { route, pathname, params } = match; + return { + id: route.id, + pathname, + params, + data: loaderData[route.id] as unknown, + handle: route.handle as unknown, + }; +} + +function getTargetMatch( + matches: AgnosticDataRouteMatch[], + location: Location | string +) { + let search = + typeof location === "string" ? parsePath(location).search : location.search; + if ( + matches[matches.length - 1].route.index && + !hasNakedIndexQuery(search || "") + ) { + return matches.slice(-2)[0]; + } + return matches.slice(-1)[0]; +} + +function createURL(location: Location | string): URL { + let base = + typeof window !== "undefined" && typeof window.location !== "undefined" + ? window.location.origin + : "unknown://unknown"; + let href = typeof location === "string" ? location : createHref(location); + return new URL(href, base); +} +//#endregion diff --git a/packages/remix-server-runtime/router/utils.ts b/packages/remix-server-runtime/router/utils.ts new file mode 100644 index 00000000000..e3dcf828a1e --- /dev/null +++ b/packages/remix-server-runtime/router/utils.ts @@ -0,0 +1,1121 @@ +// @ts-nocheck +// eslint-disable + +import type { Location, Path, To } from "./history"; +import { parsePath } from "./history"; + +/** + * Map of routeId -> data returned from a loader/action/error + */ +export interface RouteData { + [routeId: string]: any; +} + +export enum ResultType { + data = "data", + deferred = "deferred", + redirect = "redirect", + error = "error", +} + +/** + * Successful result from a loader or action + */ +export interface SuccessResult { + type: ResultType.data; + data: any; + statusCode?: number; + headers?: Headers; +} + +/** + * Successful defer() result from a loader or action + */ +export interface DeferredResult { + type: ResultType.deferred; + deferredData: DeferredData; +} + +/** + * Redirect result from a loader or action + */ +export interface RedirectResult { + type: ResultType.redirect; + status: number; + location: string; + revalidate: boolean; +} + +/** + * Unsuccessful result from a loader or action + */ +export interface ErrorResult { + type: ResultType.error; + error: any; + headers?: Headers; +} + +/** + * Result from a loader or action - potentially successful or unsuccessful + */ +export type DataResult = + | SuccessResult + | DeferredResult + | RedirectResult + | ErrorResult; + +export type FormMethod = "get" | "post" | "put" | "patch" | "delete"; +export type FormEncType = + | "application/x-www-form-urlencoded" + | "multipart/form-data"; + +/** + * @private + * Internal interface to pass around for action submissions, not intended for + * external consumption + */ +export interface Submission { + formMethod: Exclude; + formAction: string; + formEncType: FormEncType; + formData: FormData; +} + +/** + * @private + * Arguments passed to route loader/action functions. Same for now but we keep + * this as a private implementation detail in case they diverge in the future. + */ +interface DataFunctionArgs { + request: Request; + params: Params; +} + +/** + * Arguments passed to loader functions + */ +export interface LoaderFunctionArgs extends DataFunctionArgs {} + +/** + * Arguments passed to action functions + */ +export interface ActionFunctionArgs extends DataFunctionArgs {} + +/** + * Route loader function signature + */ +export interface LoaderFunction { + (args: LoaderFunctionArgs): Promise | Response | Promise | any; +} + +/** + * Route action function signature + */ +export interface ActionFunction { + (args: ActionFunctionArgs): Promise | Response | Promise | any; +} + +/** + * Route shouldRevalidate function signature. This runs after any submission + * (navigation or fetcher), so we flatten the navigation/fetcher submission + * onto the arguments. It shouldn't matter whether it came from a navigation + * or a fetcher, what really matters is the URLs and the formData since loaders + * have to re-run based on the data models that were potentially mutated. + */ +export interface ShouldRevalidateFunction { + (args: { + currentUrl: URL; + currentParams: AgnosticDataRouteMatch["params"]; + nextUrl: URL; + nextParams: AgnosticDataRouteMatch["params"]; + formMethod?: Submission["formMethod"]; + formAction?: Submission["formAction"]; + formEncType?: Submission["formEncType"]; + formData?: Submission["formData"]; + actionResult?: DataResult; + defaultShouldRevalidate: boolean; + }): boolean; +} + +/** + * A route object represents a logical route, with (optionally) its child + * routes organized in a tree-like structure. + */ +export interface AgnosticRouteObject { + caseSensitive?: boolean; + children?: AgnosticRouteObject[]; + index?: boolean; + path?: string; + id?: string; + loader?: LoaderFunction; + action?: ActionFunction; + hasErrorBoundary?: boolean; + shouldRevalidate?: ShouldRevalidateFunction; + handle?: any; +} + +/** + * A data route object, which is just a RouteObject with a required unique ID + */ +export interface AgnosticDataRouteObject extends AgnosticRouteObject { + children?: AgnosticDataRouteObject[]; + id: string; +} + +// Recursive helper for finding path parameters in the absence of wildcards +type _PathParam = + // split path into individual path segments + Path extends `${infer L}/${infer R}` + ? _PathParam | _PathParam + : // find params after `:` + Path extends `${string}:${infer Param}` + ? Param + : // otherwise, there aren't any params present + never; + +/** + * Examples: + * "/a/b/*" -> "*" + * ":a" -> "a" + * "/a/:b" -> "b" + * "/a/blahblahblah:b" -> "b" + * "/:a/:b" -> "a" | "b" + * "/:a/b/:c/*" -> "a" | "c" | "*" + */ +type PathParam = + // check if path is just a wildcard + Path extends "*" + ? "*" + : // look for wildcard at the end of the path + Path extends `${infer Rest}/*` + ? "*" | _PathParam + : // look for params in the absence of wildcards + _PathParam; + +// Attempt to parse the given string segment. If it fails, then just return the +// plain string type as a default fallback. Otherwise return the union of the +// parsed string literals that were referenced as dynamic segments in the route. +export type ParamParseKey = + // if could not find path params, fallback to `string` + [PathParam] extends [never] ? string : PathParam; + +/** + * The parameters that were parsed from the URL path. + */ +export type Params = { + readonly [key in Key]: string | undefined; +}; + +/** + * A RouteMatch contains info about how a route matched a URL. + */ +export interface AgnosticRouteMatch< + ParamKey extends string = string, + RouteObjectType extends AgnosticRouteObject = AgnosticRouteObject +> { + /** + * The names and values of dynamic parameters in the URL. + */ + params: Params; + /** + * The portion of the URL pathname that was matched. + */ + pathname: string; + /** + * The portion of the URL pathname that was matched before child routes. + */ + pathnameBase: string; + /** + * The route object that was used to match. + */ + route: RouteObjectType; +} + +export interface AgnosticDataRouteMatch + extends AgnosticRouteMatch {} + +// Walk the route tree generating unique IDs where necessary so we are working +// solely with AgnosticDataRouteObject's within the Router +export function convertRoutesToDataRoutes( + routes: AgnosticRouteObject[], + parentPath: number[] = [], + allIds: Set = new Set() +): AgnosticDataRouteObject[] { + return routes.map((route, index) => { + let treePath = [...parentPath, index]; + let id = typeof route.id === "string" ? route.id : treePath.join("-"); + invariant( + !allIds.has(id), + `Found a route id collision on id "${id}". Route ` + + "id's must be globally unique within Data Router usages" + ); + allIds.add(id); + let dataRoute: AgnosticDataRouteObject = { + ...route, + id, + children: route.children + ? convertRoutesToDataRoutes(route.children, treePath, allIds) + : undefined, + }; + return dataRoute; + }); +} + +/** + * Matches the given routes to a location and returns the match data. + * + * @see https://reactrouter.com/docs/en/v6/utils/match-routes + */ +export function matchRoutes< + RouteObjectType extends AgnosticRouteObject = AgnosticRouteObject +>( + routes: RouteObjectType[], + locationArg: Partial | string, + basename = "/" +): AgnosticRouteMatch[] | null { + let location = + typeof locationArg === "string" ? parsePath(locationArg) : locationArg; + + let pathname = stripBasename(location.pathname || "/", basename); + + if (pathname == null) { + return null; + } + + let branches = flattenRoutes(routes); + rankRouteBranches(branches); + + let matches = null; + for (let i = 0; matches == null && i < branches.length; ++i) { + matches = matchRouteBranch(branches[i], pathname); + } + + return matches; +} + +interface RouteMeta< + RouteObjectType extends AgnosticRouteObject = AgnosticRouteObject +> { + relativePath: string; + caseSensitive: boolean; + childrenIndex: number; + route: RouteObjectType; +} + +interface RouteBranch< + RouteObjectType extends AgnosticRouteObject = AgnosticRouteObject +> { + path: string; + score: number; + routesMeta: RouteMeta[]; +} + +function flattenRoutes< + RouteObjectType extends AgnosticRouteObject = AgnosticRouteObject +>( + routes: RouteObjectType[], + branches: RouteBranch[] = [], + parentsMeta: RouteMeta[] = [], + parentPath = "" +): RouteBranch[] { + routes.forEach((route, index) => { + let meta: RouteMeta = { + relativePath: route.path || "", + caseSensitive: route.caseSensitive === true, + childrenIndex: index, + route, + }; + + if (meta.relativePath.startsWith("/")) { + invariant( + meta.relativePath.startsWith(parentPath), + `Absolute route path "${meta.relativePath}" nested under path ` + + `"${parentPath}" is not valid. An absolute child route path ` + + `must start with the combined path of all its parent routes.` + ); + + meta.relativePath = meta.relativePath.slice(parentPath.length); + } + + let path = joinPaths([parentPath, meta.relativePath]); + let routesMeta = parentsMeta.concat(meta); + + // Add the children before adding this route to the array so we traverse the + // route tree depth-first and child routes appear before their parents in + // the "flattened" version. + if (route.children && route.children.length > 0) { + invariant( + route.index !== true, + `Index routes must not have child routes. Please remove ` + + `all child routes from route path "${path}".` + ); + + flattenRoutes(route.children, branches, routesMeta, path); + } + + // Routes without a path shouldn't ever match by themselves unless they are + // index routes, so don't add them to the list of possible branches. + if (route.path == null && !route.index) { + return; + } + + branches.push({ path, score: computeScore(path, route.index), routesMeta }); + }); + + return branches; +} + +function rankRouteBranches(branches: RouteBranch[]): void { + branches.sort((a, b) => + a.score !== b.score + ? b.score - a.score // Higher score first + : compareIndexes( + a.routesMeta.map((meta) => meta.childrenIndex), + b.routesMeta.map((meta) => meta.childrenIndex) + ) + ); +} + +const paramRe = /^:\w+$/; +const dynamicSegmentValue = 3; +const indexRouteValue = 2; +const emptySegmentValue = 1; +const staticSegmentValue = 10; +const splatPenalty = -2; +const isSplat = (s: string) => s === "*"; + +function computeScore(path: string, index: boolean | undefined): number { + let segments = path.split("/"); + let initialScore = segments.length; + if (segments.some(isSplat)) { + initialScore += splatPenalty; + } + + if (index) { + initialScore += indexRouteValue; + } + + return segments + .filter((s) => !isSplat(s)) + .reduce( + (score, segment) => + score + + (paramRe.test(segment) + ? dynamicSegmentValue + : segment === "" + ? emptySegmentValue + : staticSegmentValue), + initialScore + ); +} + +function compareIndexes(a: number[], b: number[]): number { + let siblings = + a.length === b.length && a.slice(0, -1).every((n, i) => n === b[i]); + + return siblings + ? // If two routes are siblings, we should try to match the earlier sibling + // first. This allows people to have fine-grained control over the matching + // behavior by simply putting routes with identical paths in the order they + // want them tried. + a[a.length - 1] - b[b.length - 1] + : // Otherwise, it doesn't really make sense to rank non-siblings by index, + // so they sort equally. + 0; +} + +function matchRouteBranch< + ParamKey extends string = string, + RouteObjectType extends AgnosticRouteObject = AgnosticRouteObject +>( + branch: RouteBranch, + pathname: string +): AgnosticRouteMatch[] | null { + let { routesMeta } = branch; + + let matchedParams = {}; + let matchedPathname = "/"; + let matches: AgnosticRouteMatch[] = []; + for (let i = 0; i < routesMeta.length; ++i) { + let meta = routesMeta[i]; + let end = i === routesMeta.length - 1; + let remainingPathname = + matchedPathname === "/" + ? pathname + : pathname.slice(matchedPathname.length) || "/"; + let match = matchPath( + { path: meta.relativePath, caseSensitive: meta.caseSensitive, end }, + remainingPathname + ); + + if (!match) return null; + + Object.assign(matchedParams, match.params); + + let route = meta.route; + + matches.push({ + // TODO: Can this as be avoided? + params: matchedParams as Params, + pathname: joinPaths([matchedPathname, match.pathname]), + pathnameBase: normalizePathname( + joinPaths([matchedPathname, match.pathnameBase]) + ), + route, + }); + + if (match.pathnameBase !== "/") { + matchedPathname = joinPaths([matchedPathname, match.pathnameBase]); + } + } + + return matches; +} + +/** + * Returns a path with params interpolated. + * + * @see https://reactrouter.com/docs/en/v6/utils/generate-path + */ +export function generatePath( + path: Path, + params: { + [key in PathParam]: string; + } = {} as any +): string { + return path + .replace(/:(\w+)/g, (_, key: PathParam) => { + invariant(params[key] != null, `Missing ":${key}" param`); + return params[key]!; + }) + .replace(/(\/?)\*/, (_, prefix, __, str) => { + const star = "*" as PathParam; + + if (params[star] == null) { + // If no splat was provided, trim the trailing slash _unless_ it's + // the entire path + return str === "/*" ? "/" : ""; + } + + // Apply the splat + return `${prefix}${params[star]}`; + }); +} + +/** + * A PathPattern is used to match on some portion of a URL pathname. + */ +export interface PathPattern { + /** + * A string to match against a URL pathname. May contain `:id`-style segments + * to indicate placeholders for dynamic parameters. May also end with `/*` to + * indicate matching the rest of the URL pathname. + */ + path: Path; + /** + * Should be `true` if the static portions of the `path` should be matched in + * the same case. + */ + caseSensitive?: boolean; + /** + * Should be `true` if this pattern should match the entire URL pathname. + */ + end?: boolean; +} + +/** + * A PathMatch contains info about how a PathPattern matched on a URL pathname. + */ +export interface PathMatch { + /** + * The names and values of dynamic parameters in the URL. + */ + params: Params; + /** + * The portion of the URL pathname that was matched. + */ + pathname: string; + /** + * The portion of the URL pathname that was matched before child routes. + */ + pathnameBase: string; + /** + * The pattern that was used to match. + */ + pattern: PathPattern; +} + +type Mutable = { + -readonly [P in keyof T]: T[P]; +}; + +/** + * Performs pattern matching on a URL pathname and returns information about + * the match. + * + * @see https://reactrouter.com/docs/en/v6/utils/match-path + */ +export function matchPath< + ParamKey extends ParamParseKey, + Path extends string +>( + pattern: PathPattern | Path, + pathname: string +): PathMatch | null { + if (typeof pattern === "string") { + pattern = { path: pattern, caseSensitive: false, end: true }; + } + + let [matcher, paramNames] = compilePath( + pattern.path, + pattern.caseSensitive, + pattern.end + ); + + let match = pathname.match(matcher); + if (!match) return null; + + let matchedPathname = match[0]; + let pathnameBase = matchedPathname.replace(/(.)\/+$/, "$1"); + let captureGroups = match.slice(1); + let params: Params = paramNames.reduce>( + (memo, paramName, index) => { + // We need to compute the pathnameBase here using the raw splat value + // instead of using params["*"] later because it will be decoded then + if (paramName === "*") { + let splatValue = captureGroups[index] || ""; + pathnameBase = matchedPathname + .slice(0, matchedPathname.length - splatValue.length) + .replace(/(.)\/+$/, "$1"); + } + + memo[paramName] = safelyDecodeURIComponent( + captureGroups[index] || "", + paramName + ); + return memo; + }, + {} + ); + + return { + params, + pathname: matchedPathname, + pathnameBase, + pattern, + }; +} + +function compilePath( + path: string, + caseSensitive = false, + end = true +): [RegExp, string[]] { + warning( + path === "*" || !path.endsWith("*") || path.endsWith("/*"), + `Route path "${path}" will be treated as if it were ` + + `"${path.replace(/\*$/, "/*")}" because the \`*\` character must ` + + `always follow a \`/\` in the pattern. To get rid of this warning, ` + + `please change the route path to "${path.replace(/\*$/, "/*")}".` + ); + + let paramNames: string[] = []; + let regexpSource = + "^" + + path + .replace(/\/*\*?$/, "") // Ignore trailing / and /*, we'll handle it below + .replace(/^\/*/, "/") // Make sure it has a leading / + .replace(/[\\.*+^$?{}|()[\]]/g, "\\$&") // Escape special regex chars + .replace(/:(\w+)/g, (_: string, paramName: string) => { + paramNames.push(paramName); + return "([^\\/]+)"; + }); + + if (path.endsWith("*")) { + paramNames.push("*"); + regexpSource += + path === "*" || path === "/*" + ? "(.*)$" // Already matched the initial /, just match the rest + : "(?:\\/(.+)|\\/*)$"; // Don't include the / in params["*"] + } else { + regexpSource += end + ? "\\/*$" // When matching to the end, ignore trailing slashes + : // Otherwise, match a word boundary or a proceeding /. The word boundary restricts + // parent routes to matching only their own words and nothing more, e.g. parent + // route "/home" should not match "/home2". + // Additionally, allow paths starting with `.`, `-`, `~`, and url-encoded entities, + // but do not consume the character in the matched path so they can match against + // nested paths. + "(?:(?=[@.~-]|%[0-9A-F]{2})|\\b|\\/|$)"; + } + + let matcher = new RegExp(regexpSource, caseSensitive ? undefined : "i"); + + return [matcher, paramNames]; +} + +function safelyDecodeURIComponent(value: string, paramName: string) { + try { + return decodeURIComponent(value); + } catch (error) { + warning( + false, + `The value for the URL param "${paramName}" will not be decoded because` + + ` the string "${value}" is a malformed URL segment. This is probably` + + ` due to a bad percent encoding (${error}).` + ); + + return value; + } +} + +/** + * @private + */ +export function stripBasename( + pathname: string, + basename: string +): string | null { + if (basename === "/") return pathname; + + if (!pathname.toLowerCase().startsWith(basename.toLowerCase())) { + return null; + } + + // We want to leave trailing slash behavior in the user's control, so if they + // specify a basename with a trailing slash, we should support it + let startIndex = basename.endsWith("/") + ? basename.length - 1 + : basename.length; + let nextChar = pathname.charAt(startIndex); + if (nextChar && nextChar !== "/") { + // pathname does not start with basename/ + return null; + } + + return pathname.slice(startIndex) || "/"; +} + +/** + * @private + */ +export function invariant(value: boolean, message?: string): asserts value; +export function invariant( + value: T | null | undefined, + message?: string +): asserts value is T; +export function invariant(value: any, message?: string) { + if (value === false || value === null || typeof value === "undefined") { + throw new Error(message); + } +} + +/** + * @private + */ +export function warning(cond: any, message: string): void { + if (!cond) { + // eslint-disable-next-line no-console + if (typeof console !== "undefined") console.warn(message); + + try { + // Welcome to debugging React Router! + // + // This error is thrown as a convenience so you can more easily + // find the source for a warning that appears in the console by + // enabling "pause on exceptions" in your JavaScript debugger. + throw new Error(message); + // eslint-disable-next-line no-empty + } catch (e) {} + } +} + +/** + * Returns a resolved path object relative to the given pathname. + * + * @see https://reactrouter.com/docs/en/v6/utils/resolve-path + */ +export function resolvePath(to: To, fromPathname = "/"): Path { + let { + pathname: toPathname, + search = "", + hash = "", + } = typeof to === "string" ? parsePath(to) : to; + + let pathname = toPathname + ? toPathname.startsWith("/") + ? toPathname + : resolvePathname(toPathname, fromPathname) + : fromPathname; + + return { + pathname, + search: normalizeSearch(search), + hash: normalizeHash(hash), + }; +} + +function resolvePathname(relativePath: string, fromPathname: string): string { + let segments = fromPathname.replace(/\/+$/, "").split("/"); + let relativeSegments = relativePath.split("/"); + + relativeSegments.forEach((segment) => { + if (segment === "..") { + // Keep the root "" segment so the pathname starts at / + if (segments.length > 1) segments.pop(); + } else if (segment !== ".") { + segments.push(segment); + } + }); + + return segments.length > 1 ? segments.join("/") : "/"; +} + +/** + * @private + */ +export function resolveTo( + toArg: To, + routePathnames: string[], + locationPathname: string, + isPathRelative = false +): Path { + let to = typeof toArg === "string" ? parsePath(toArg) : { ...toArg }; + let isEmptyPath = toArg === "" || to.pathname === ""; + let toPathname = isEmptyPath ? "/" : to.pathname; + + let from: string; + + // Routing is relative to the current pathname if explicitly requested. + // + // If a pathname is explicitly provided in `to`, it should be relative to the + // route context. This is explained in `Note on `` values` in our + // migration guide from v5 as a means of disambiguation between `to` values + // that begin with `/` and those that do not. However, this is problematic for + // `to` values that do not provide a pathname. `to` can simply be a search or + // hash string, in which case we should assume that the navigation is relative + // to the current location's pathname and *not* the route pathname. + if (isPathRelative || toPathname == null) { + from = locationPathname; + } else { + let routePathnameIndex = routePathnames.length - 1; + + if (toPathname.startsWith("..")) { + let toSegments = toPathname.split("/"); + + // Each leading .. segment means "go up one route" instead of "go up one + // URL segment". This is a key difference from how works and a + // major reason we call this a "to" value instead of a "href". + while (toSegments[0] === "..") { + toSegments.shift(); + routePathnameIndex -= 1; + } + + to.pathname = toSegments.join("/"); + } + + // If there are more ".." segments than parent routes, resolve relative to + // the root / URL. + from = routePathnameIndex >= 0 ? routePathnames[routePathnameIndex] : "/"; + } + + let path = resolvePath(to, from); + + // Ensure the pathname has a trailing slash if the original "to" had one + let hasExplicitTrailingSlash = + toPathname && toPathname !== "/" && toPathname.endsWith("/"); + // Or if this was a link to the current path which has a trailing slash + let hasCurrentTrailingSlash = + (isEmptyPath || toPathname === ".") && locationPathname.endsWith("/"); + if ( + !path.pathname.endsWith("/") && + (hasExplicitTrailingSlash || hasCurrentTrailingSlash) + ) { + path.pathname += "/"; + } + + return path; +} + +/** + * @private + */ +export function getToPathname(to: To): string | undefined { + // Empty strings should be treated the same as / paths + return to === "" || (to as Path).pathname === "" + ? "/" + : typeof to === "string" + ? parsePath(to).pathname + : to.pathname; +} + +/** + * @private + */ +export const joinPaths = (paths: string[]): string => + paths.join("/").replace(/\/\/+/g, "/"); + +/** + * @private + */ +export const normalizePathname = (pathname: string): string => + pathname.replace(/\/+$/, "").replace(/^\/*/, "/"); + +/** + * @private + */ +export const normalizeSearch = (search: string): string => + !search || search === "?" + ? "" + : search.startsWith("?") + ? search + : "?" + search; + +/** + * @private + */ +export const normalizeHash = (hash: string): string => + !hash || hash === "#" ? "" : hash.startsWith("#") ? hash : "#" + hash; + +export type JsonFunction = ( + data: Data, + init?: number | ResponseInit +) => Response; + +/** + * This is a shortcut for creating `application/json` responses. Converts `data` + * to JSON and sets the `Content-Type` header. + */ +export const json: JsonFunction = (data, init = {}) => { + let responseInit = typeof init === "number" ? { status: init } : init; + + let headers = new Headers(responseInit.headers); + if (!headers.has("Content-Type")) { + headers.set("Content-Type", "application/json; charset=utf-8"); + } + + return new Response(JSON.stringify(data), { + ...responseInit, + headers, + }); +}; + +export interface TrackedPromise extends Promise { + _tracked?: boolean; + _data?: any; + _error?: any; +} + +export class AbortedDeferredError extends Error {} + +export class DeferredData { + private pendingKeys: Set = new Set(); + private controller: AbortController; + private abortPromise: Promise; + private unlistenAbortSignal: () => void; + private subscriber?: (aborted: boolean) => void = undefined; + data: Record; + + constructor(data: Record) { + invariant( + data && typeof data === "object" && !Array.isArray(data), + "defer() only accepts plain objects" + ); + + // Set up an AbortController + Promise we can race against to exit early + // cancellation + let reject: (e: AbortedDeferredError) => void; + this.abortPromise = new Promise((_, r) => (reject = r)); + this.controller = new AbortController(); + let onAbort = () => + reject(new AbortedDeferredError("Deferred data aborted")); + this.unlistenAbortSignal = () => + this.controller.signal.removeEventListener("abort", onAbort); + this.controller.signal.addEventListener("abort", onAbort); + + this.data = Object.entries(data).reduce( + (acc, [key, value]) => + Object.assign(acc, { + [key]: this.trackPromise(key, value), + }), + {} + ); + } + + private trackPromise( + key: string | number, + value: Promise | unknown + ): TrackedPromise | unknown { + if (!(value instanceof Promise)) { + return value; + } + + this.pendingKeys.add(key); + + // We store a little wrapper promise that will be extended with + // _data/_error props upon resolve/reject + let promise: TrackedPromise = Promise.race([value, this.abortPromise]).then( + (data) => this.onSettle(promise, key, null, data as unknown), + (error) => this.onSettle(promise, key, error as unknown) + ); + + // Register rejection listeners to avoid uncaught promise rejections on + // errors or aborted deferred values + promise.catch(() => {}); + + Object.defineProperty(promise, "_tracked", { get: () => true }); + return promise; + } + + private onSettle( + promise: TrackedPromise, + key: string | number, + error: unknown, + data?: unknown + ): unknown { + if ( + this.controller.signal.aborted && + error instanceof AbortedDeferredError + ) { + this.unlistenAbortSignal(); + Object.defineProperty(promise, "_error", { get: () => error }); + return Promise.reject(error); + } + + this.pendingKeys.delete(key); + + if (this.done) { + // Nothing left to abort! + this.unlistenAbortSignal(); + } + + const subscriber = this.subscriber; + if (error) { + Object.defineProperty(promise, "_error", { get: () => error }); + subscriber && subscriber(false); + return Promise.reject(error); + } + + Object.defineProperty(promise, "_data", { get: () => data }); + subscriber && subscriber(false); + return data; + } + + subscribe(fn: (aborted: boolean) => void) { + this.subscriber = fn; + } + + cancel() { + this.controller.abort(); + this.pendingKeys.forEach((v, k) => this.pendingKeys.delete(k)); + let subscriber = this.subscriber; + subscriber && subscriber(true); + } + + async resolveData(signal: AbortSignal) { + let aborted = false; + if (!this.done) { + let onAbort = () => this.cancel(); + signal.addEventListener("abort", onAbort); + aborted = await new Promise((resolve) => { + this.subscribe((aborted) => { + signal.removeEventListener("abort", onAbort); + if (aborted || this.done) { + resolve(aborted); + } + }); + }); + } + return aborted; + } + + get done() { + return this.pendingKeys.size === 0; + } + + get unwrappedData() { + invariant( + this.data !== null && this.done, + "Can only unwrap data on initialized and settled deferreds" + ); + + return Object.entries(this.data).reduce( + (acc, [key, value]) => + Object.assign(acc, { + [key]: unwrapTrackedPromise(value), + }), + {} + ); + } +} + +function isTrackedPromise(value: any): value is TrackedPromise { + return ( + value instanceof Promise && (value as TrackedPromise)._tracked === true + ); +} + +function unwrapTrackedPromise(value: any) { + if (!isTrackedPromise(value)) { + return value; + } + + if (value._error) { + throw value._error; + } + return value._data; +} + +export function defer(data: Record) { + return new DeferredData(data); +} + +export type RedirectFunction = ( + url: string, + init?: number | ResponseInit +) => Response; + +/** + * A redirect response. Sets the status code and the `Location` header. + * Defaults to "302 Found". + */ +export const redirect: RedirectFunction = (url, init = 302) => { + let responseInit = init; + if (typeof responseInit === "number") { + responseInit = { status: responseInit }; + } else if (typeof responseInit.status === "undefined") { + responseInit.status = 302; + } + + let headers = new Headers(responseInit.headers); + headers.set("Location", url); + + return new Response(null, { + ...responseInit, + headers, + }); +}; + +/** + * @private + * Utility class we use to hold auto-unwrapped 4xx/5xx Response bodies + */ +export class ErrorResponse { + status: number; + statusText: string; + data: any; + + constructor(status: number, statusText: string | undefined, data: any) { + this.status = status; + this.statusText = statusText || ""; + this.data = data; + } +} + +/** + * Check if the given error is an ErrorResponse generated from a 4xx/5xx + * Response throw from an action/loader + */ +export function isRouteErrorResponse(e: any): e is ErrorResponse { + return e instanceof ErrorResponse; +} diff --git a/packages/remix-server-runtime/routes.ts b/packages/remix-server-runtime/routes.ts index c6e646983c0..98fc8b0b333 100644 --- a/packages/remix-server-runtime/routes.ts +++ b/packages/remix-server-runtime/routes.ts @@ -1,3 +1,7 @@ +// TODO: RRR - Change import to @remix-run/router +import type { AgnosticDataRouteObject } from "./router"; +import type { AppLoadContext } from "./data"; +import { callRouteAction, callRouteLoader } from "./data"; import type { ServerRouteModule } from "./routeModules"; export interface RouteManifest { @@ -41,3 +45,47 @@ export function createRoutes( children: createRoutes(manifest, id), })); } + +// Convert the Remix ServerManifest into DataRouteObject's for use with +// createStaticHandler +export function createStaticHandlerDataRoutes( + manifest: ServerRouteManifest, + loadContext: AppLoadContext, + parentId?: string +): AgnosticDataRouteObject[] { + return Object.values(manifest) + .filter((route) => route.parentId === parentId) + .map((route) => ({ + caseSensitive: route.caseSensitive, + children: createStaticHandlerDataRoutes(manifest, loadContext, route.id), + // Always include root due to default boundaries + hasErrorBoundary: + route.id === "root" || + route.module.CatchBoundary != null || + route.module.ErrorBoundary != null, + id: route.id, + index: route.index, + path: route.path, + loader: route.module.loader + ? (args) => + callRouteLoader({ + ...args, + routeId: route.id, + loader: route.module.loader, + loadContext, + }) + : undefined, + action: route.module.action + ? (args) => + callRouteAction({ + ...args, + routeId: route.id, + action: route.module.action, + loadContext, + }) + : undefined, + handle: route.module.handle, + // TODO: RRR - Implement! + shouldRevalidate: () => true, + })); +} diff --git a/packages/remix-server-runtime/server.ts b/packages/remix-server-runtime/server.ts index 9d339acad72..953a05c09d7 100644 --- a/packages/remix-server-runtime/server.ts +++ b/packages/remix-server-runtime/server.ts @@ -1,3 +1,6 @@ +// TODO: RRR - Change import to @remix-run/router +import type { StaticHandler } from "./router"; +import { unstable_createStaticHandler } from "./router"; import type { AppLoadContext } from "./data"; import { callRouteAction, callRouteLoader, extractData } from "./data"; import type { AppState } from "./errors"; @@ -6,11 +9,12 @@ import type { EntryContext } from "./entry"; import { createEntryMatches, createEntryRouteModules } from "./entry"; import { serializeError } from "./errors"; import { getDocumentHeaders } from "./headers"; +import invariant from "./invariant"; import { ServerMode, isServerMode } from "./mode"; import type { RouteMatch } from "./routeMatching"; import { matchServerRoutes } from "./routeMatching"; import type { ServerRoute } from "./routes"; -import { createRoutes } from "./routes"; +import { createStaticHandlerDataRoutes, createRoutes } from "./routes"; import { json, isRedirectResponse, isCatchResponse } from "./responses"; import { createServerHandoffString } from "./serverHandoff"; @@ -24,6 +28,9 @@ export type CreateRequestHandlerFunction = ( mode?: string ) => RequestHandler; +// This can be toggled to true for experimental releases +const ENABLE_REMIX_ROUTER = process.env.ENABLE_REMIX_ROUTER; + export const createRequestHandler: CreateRequestHandlerFunction = ( build, mode @@ -44,13 +51,46 @@ export const createRequestHandler: CreateRequestHandlerFunction = ( handleDataRequest: build.entry.module.handleDataRequest, serverMode, }); - } else if (matches && !matches[matches.length - 1].route.module.default) { - response = await handleResourceRequest({ - request, + } else if ( + matches && + matches[matches.length - 1].route.module.default == null + ) { + let responsePromise = handleResourceRequest({ + request: + // We need to clone the request here instead of the call to the new + // handler otherwise the first handler will lock the body for the other. + // Cloning here allows the new handler to be the stream reader and delegate + // chunks back to this cloned request. + ENABLE_REMIX_ROUTER ? request.clone() : request, loadContext, matches, serverMode, }); + + if (ENABLE_REMIX_ROUTER) { + // TODO: RRR - Move up in function context and re-use for all calls to + // the new functions as they will all need an instance of this handler + let staticHandler = unstable_createStaticHandler( + createStaticHandlerDataRoutes(build.routes, loadContext) + ); + + let [response, remixRouterResponse] = await Promise.all([ + responsePromise, + handleResourceRequestRR( + serverMode, + staticHandler, + matches.slice(-1)[0].route.id, + request + ), + ]); + + assertResponsesMatch(response, remixRouterResponse); + + console.log("Returning Remix Router Resource Request Response"); + responsePromise = Promise.resolve(remixRouterResponse); + } + + response = await responsePromise; } else { response = await handleDocumentRequest({ build, @@ -111,7 +151,9 @@ async function handleDataRequest({ response = await callRouteAction({ loadContext, - match, + action: match.route.module.action, + routeId: match.route.id, + params: match.params, request: request, }); } else { @@ -129,7 +171,13 @@ async function handleDataRequest({ } match = tempMatch; - response = await callRouteLoader({ loadContext, match, request }); + response = await callRouteLoader({ + loadContext, + loader: match.route.module.loader, + routeId: match.route.id, + params: match.params, + request, + }); } if (isRedirectResponse(response)) { @@ -226,7 +274,9 @@ async function handleDocumentRequest({ try { actionResponse = await callRouteAction({ loadContext, - match: actionMatch, + action: actionMatch.route.module.action, + routeId: actionMatch.route.id, + params: actionMatch.params, request: request, }); @@ -303,7 +353,9 @@ async function handleDocumentRequest({ match.route.module.loader ? callRouteLoader({ loadContext, - match, + loader: match.route.module.loader, + routeId: match.route.id, + params: match.params, request: loaderRequest, }) : Promise.resolve(undefined) @@ -488,27 +540,30 @@ async function handleDocumentRequest({ entryContext ); } catch (error: any) { - if (serverMode !== ServerMode.Test) { - console.error(error); - } - - let message = "Unexpected Server Error"; - - if (serverMode === ServerMode.Development) { - message += `\n\n${String(error)}`; - } - - // Good grief folks, get your act together 😂! - return new Response(message, { - status: 500, - headers: { - "Content-Type": "text/plain", - }, - }); + return returnLastResortErrorResponse(error, serverMode); } } } +async function handleResourceRequestRR( + serverMode: ServerMode, + staticHandler: StaticHandler, + routeId: string, + request: Request +) { + try { + let response = await staticHandler.queryRoute(request, routeId); + // Remix should always be returning responses from loaders and actions + invariant( + response instanceof Response, + "Expected a Response to be returned from queryRoute" + ); + return response; + } catch (error) { + return returnLastResortErrorResponse(error, serverMode); + } +} + async function handleResourceRequest({ loadContext, matches, @@ -524,28 +579,24 @@ async function handleResourceRequest({ try { if (isActionRequest(request)) { - return await callRouteAction({ match, loadContext, request }); + return await callRouteAction({ + loadContext, + action: match.route.module.action, + routeId: match.route.id, + params: match.params, + request, + }); } else { - return await callRouteLoader({ match, loadContext, request }); + return await callRouteLoader({ + loadContext, + loader: match.route.module.loader, + routeId: match.route.id, + params: match.params, + request, + }); } } catch (error: any) { - if (serverMode !== ServerMode.Test) { - console.error(error); - } - - let message = "Unexpected Server Error"; - - if (serverMode === ServerMode.Development) { - message += `\n\n${String(error)}`; - } - - // Good grief folks, get your act together 😂! - return new Response(message, { - status: 500, - headers: { - "Content-Type": "text/plain", - }, - }); + return returnLastResortErrorResponse(error, serverMode); } } @@ -647,3 +698,56 @@ function getRenderableMatches( return matches.slice(0, lastRenderableIndex + 1); } + +async function assert( + a: Response, + b: Response, + accessor: (r: Response) => object | Promise, + message: string +) { + let aStr = JSON.stringify(await accessor(a)); + let bStr = JSON.stringify(await accessor(b)); + if (aStr !== bStr) { + console.error(message); + console.error("Response 1:\n", aStr); + console.error("Response 2:\n", bStr); + throw new Error(message); + } +} + +async function assertResponsesMatch(_a: Response, _b: Response) { + let a = _a.clone(); + let b = _b.clone(); + assert( + a, + b, + (r) => Object.fromEntries(r.headers.entries()), + "Headers did not match!" + ); + + if (a.headers.get("Content-Type")?.startsWith("application/json")) { + assert(a, b, (r) => r.json(), "JSON response body did not match!"); + } else { + assert(a, b, (r) => r.text(), "Non-JSON response body did not match!"); + } +} + +function returnLastResortErrorResponse(error: any, serverMode?: ServerMode) { + if (serverMode !== ServerMode.Test) { + console.error(error); + } + + let message = "Unexpected Server Error"; + + if (serverMode !== ServerMode.Production) { + message += `\n\n${String(error)}`; + } + + // Good grief folks, get your act together 😂! + return new Response(message, { + status: 500, + headers: { + "Content-Type": "text/plain", + }, + }); +} diff --git a/yarn.lock b/yarn.lock index 7b62707b2fa..083045bda93 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2549,6 +2549,14 @@ is-module "^1.0.0" resolve "^1.19.0" +"@rollup/plugin-replace@^4.0.0": + version "4.0.0" + resolved "https://registry.yarnpkg.com/@rollup/plugin-replace/-/plugin-replace-4.0.0.tgz#e34c457d6a285f0213359740b43f39d969b38a67" + integrity sha512-+rumQFiaNac9y64OHtkHGmdjm7us9bo1PlbgQfdihQtuNxzjpaB064HbRnewUOggLQxVCCyINfStkgmBeQpv1g== + dependencies: + "@rollup/pluginutils" "^3.1.0" + magic-string "^0.25.7" + "@rollup/pluginutils@^3.0.8", "@rollup/pluginutils@^3.1.0": version "3.1.0" resolved "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-3.1.0.tgz" @@ -8905,6 +8913,13 @@ magic-string@^0.25.3: dependencies: sourcemap-codec "^1.4.4" +magic-string@^0.25.7: + version "0.25.9" + resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.25.9.tgz#de7f9faf91ef8a1c91d02c2e5314c8277dbcdd1c" + integrity sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ== + dependencies: + sourcemap-codec "^1.4.8" + make-dir@^2.0.0, make-dir@^2.1.0: version "2.1.0" resolved "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz" @@ -8922,7 +8937,7 @@ make-dir@^3.0.0: make-error@^1.1.1: version "1.3.6" - resolved "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz" + resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.6.tgz#2eb2e37ea9b67c4891f684a1394799af484cf7a2" integrity sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw== makeerror@1.0.x: @@ -11293,7 +11308,7 @@ rollup-plugin-node-polyfills@^0.2.1: rollup-pluginutils@^2.8.1: version "2.8.2" - resolved "https://registry.npmjs.org/rollup-pluginutils/-/rollup-pluginutils-2.8.2.tgz" + resolved "https://registry.yarnpkg.com/rollup-pluginutils/-/rollup-pluginutils-2.8.2.tgz#72f2af0748b592364dbd3389e600e5a9444a351e" integrity sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ== dependencies: estree-walker "^0.6.1" @@ -11726,9 +11741,9 @@ source-map@^0.7.0, source-map@^0.7.3: resolved "https://registry.npmjs.org/source-map/-/source-map-0.7.3.tgz" integrity sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ== -sourcemap-codec@^1.4.4: +sourcemap-codec@^1.4.4, sourcemap-codec@^1.4.8: version "1.4.8" - resolved "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz" + resolved "https://registry.yarnpkg.com/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz#ea804bd94857402e6992d05a38ef1ae35a9ab4c4" integrity sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA== space-separated-tokens@^2.0.0: