Skip to content

Commit d67ccbb

Browse files
committed
single flight mutations
1 parent 76724af commit d67ccbb

7 files changed

Lines changed: 89 additions & 16 deletions

File tree

.changeset/many-wasps-care.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@solidjs/router": patch
3+
---
4+
5+
single flight mutations

src/data/action.ts

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ import { $TRACK, createMemo, createSignal, JSX, onCleanup, getOwner } from "soli
22
import { isServer } from "solid-js/web";
33
import { useRouter } from "../routing.js";
44
import { RouterContext, Submission, Navigator } from "../types.js";
5-
import { redirectStatusCodes, mockBase } from "../utils.js";
6-
import { cacheKeyOp, hashKey, revalidate } from "./cache.js";
5+
import { mockBase } from "../utils.js";
6+
import { cacheKeyOp, hashKey, revalidate, cache } from "./cache.js";
77

88
export type Action<T extends Array<any>, U> = (T extends [FormData] | []
99
? JSX.SerializableAttributeValue
@@ -60,10 +60,14 @@ export function action<T extends Array<any>, U = void>(
6060
name?: string
6161
): Action<T, U> {
6262
function mutate(this: RouterContext, ...variables: T) {
63-
const p = fn(...variables);
63+
const router = this;
64+
const p = (
65+
router.singleFlight && (fn as any).withOptions
66+
? (fn as any).withOptions({ headers: { "X-Single-Flight": "true" } })
67+
: fn
68+
)(...variables);
6469
const [result, setResult] = createSignal<{ data?: U }>();
6570
let submission: Submission<T, U>;
66-
const router = this;
6771
async function handler(res: any) {
6872
const data = await handleResponse(res as any, router.navigatorFactory());
6973
data ? setResult({ data }) : submission.clear();
@@ -135,11 +139,24 @@ const hashString = (s: string) =>
135139
async function handleResponse(response: Response, navigate: Navigator) {
136140
let data: any;
137141
let keys: string[] | undefined;
142+
let invalidateKeys: string[] | undefined;
138143
if (response instanceof Response) {
139144
if (response.headers.has("X-Revalidate"))
140-
keys = response.headers.get("X-Revalidate")!.split(",");
141-
if ((response as any).customBody) data = await (response as any).customBody();
142-
if (redirectStatusCodes.has(response.status)) {
145+
keys = invalidateKeys = response.headers.get("X-Revalidate")!.split(",");
146+
if ((response as any).customBody) {
147+
data = await (response as any).customBody();
148+
if (response.headers.has("X-Single-Flight")) {
149+
keys || (keys = []);
150+
invalidateKeys || (invalidateKeys = []);
151+
Object.keys(data).forEach(key => {
152+
if (key === "_$value") return;
153+
keys!.push(key);
154+
cache.set(key, data[key]);
155+
});
156+
data = data._$value;
157+
}
158+
}
159+
if (response.headers.has("Location")) {
143160
const locationUrl = response.headers.get("Location") || "/";
144161
if (locationUrl.startsWith("http")) {
145162
window.location.href = locationUrl;
@@ -149,7 +166,7 @@ async function handleResponse(response: Response, navigate: Navigator) {
149166
}
150167
} else data = response;
151168
// invalidate
152-
cacheKeyOp(keys, entry => (entry[0] = 0));
169+
cacheKeyOp(invalidateKeys, entry => (entry[0] = 0));
153170
// trigger revalidation
154171
await revalidate(keys, false);
155172
return data;

src/data/cache.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import {
1010
import { createStore, reconcile, type ReconcileOptions } from "solid-js/store";
1111
import { getRequestEvent, isServer } from "solid-js/web";
1212
import { useNavigate, getIntent } from "../routing.js";
13-
import { redirectStatusCodes } from "../utils.js";
1413
import { CacheEntry } from "../types.js";
1514

1615
const LocationHeader = "Location";
@@ -76,6 +75,20 @@ export function cache<T extends (...args: any) => U | Response, U>(
7675
const key = name + hashKey(args);
7776
let cached = cache.get(key) as CacheEntry;
7877
let tracking;
78+
if (isServer) {
79+
const e = getRequestEvent();
80+
if (e) {
81+
const dataOnly = (e.router || (e.router = {})).dataOnly;
82+
if (dataOnly) {
83+
const data = e && (e.router.data || (e.router.data = {}));
84+
if (data && key in data) return data[key];
85+
if (Array.isArray(dataOnly) && !dataOnly.includes(key)) {
86+
data[key] = undefined;
87+
return Promise.resolve();
88+
}
89+
}
90+
}
91+
}
7992
if (getListener() && !isServer) {
8093
tracking = true;
8194
onCleanup(() => cached[3].count--);
@@ -118,6 +131,7 @@ export function cache<T extends (...args: any) => U | Response, U>(
118131
!(sharedConfig.context as any).noHydrate
119132
) {
120133
const e = getRequestEvent();
134+
e && e.router!.dataOnly && (e.router!.data![key] = res);
121135
(!e || !e.serverOnly) && (sharedConfig.context as any).serialize(key, res);
122136
}
123137

@@ -148,7 +162,7 @@ export function cache<T extends (...args: any) => U | Response, U>(
148162
function handleResponse(error: boolean) {
149163
return async (v: U | Response) => {
150164
if (v instanceof Response) {
151-
if (redirectStatusCodes.has(v.status)) {
165+
if (v.headers.has("Location")) {
152166
if (navigate) {
153167
startTransition(() => {
154168
let url = (v as Response).headers.get(LocationHeader);

src/routers/components.tsx

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/*@refresh skip*/
22

33
import type { Component, JSX } from "solid-js";
4-
import { getRequestEvent, isServer } from "solid-js/web";
4+
import { type RequestEvent, getRequestEvent, isServer } from "solid-js/web";
55
import { children, createMemo, createRoot, mergeProps, on, Show } from "solid-js";
66
import {
77
createBranches,
@@ -21,15 +21,17 @@ import type {
2121
RouterContext,
2222
Branch,
2323
RouteSectionProps
24-
} from "../types.ts";
25-
import { createMemoObject } from "../utils.js";
24+
} from "../types.js";
25+
import { createMemoObject, extractSearchParams } from "../utils.js";
2626

2727
export type BaseRouterProps = {
2828
base?: string;
2929
/**
3030
* A component that wraps the content of every route.
3131
*/
3232
root?: Component<RouteSectionProps>;
33+
rootLoad?: RouteLoadFunc;
34+
singleFlight: boolean;
3335
children?: JSX.Element | RouteDefinition | RouteDefinition[];
3436
};
3537

@@ -45,7 +47,7 @@ export const createRouterComponent = (router: RouterIntegration) => (props: Base
4547
props.base || ""
4648
)
4749
);
48-
const routerState = createRouterContext(router, branches, { base });
50+
const routerState = createRouterContext(router, branches, { base, singleFlight: props.singleFlight });
4951
router.create && router.create(routerState);
5052

5153
return (
@@ -62,6 +64,10 @@ function Routes(props: { routerState: RouterContext; branches: Branch[] }) {
6264

6365
if (isServer) {
6466
const e = getRequestEvent();
67+
if (e && e.router && e.router.dataOnly) {
68+
dataOnly(e, props.branches);
69+
return;
70+
}
6571
e &&
6672
((e.router || (e.router = {})).matches ||
6773
(e.router.matches = matches().map(({ route, path, params }) => ({
@@ -153,3 +159,30 @@ export const Route = <S extends string, T = unknown>(props: RouteProps<S, T>) =>
153159
}
154160
}) as unknown as JSX.Element;
155161
};
162+
163+
// for data only mode with single flight mutations
164+
function dataOnly(event: RequestEvent, branches: Branch[]) {
165+
const url = new URL(event.request.url);
166+
const prevMatches = getRouteMatches(
167+
branches,
168+
new URL(event.router!.previousUrl || event.request.url).pathname
169+
);
170+
const matches = getRouteMatches(branches, url.pathname);
171+
for (let match = 0; match < matches.length; match++) {
172+
if (!prevMatches[match] || matches[match].route !== prevMatches[match].route) event.router!.dataOnly = true;
173+
const { route, params } = matches[match];
174+
route.load &&
175+
route.load({
176+
params,
177+
location: {
178+
pathname: url.pathname,
179+
search: url.search,
180+
hash: url.hash,
181+
query: extractSearchParams(url),
182+
state: null,
183+
key: ""
184+
},
185+
intent: "preload"
186+
});
187+
}
188+
}

src/routing.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -269,7 +269,7 @@ export function getIntent() {
269269
export function createRouterContext(
270270
integration: RouterIntegration,
271271
getBranches?: () => Branch[],
272-
options: { base?: string } = {}
272+
options: { base?: string, singleFlight?: boolean } = {}
273273
): RouterContext {
274274
const {
275275
signal: [source, setSource],
@@ -339,6 +339,7 @@ export function createRouterContext(
339339
navigatorFactory,
340340
beforeLeave,
341341
preloadRoute,
342+
singleFlight: options.singleFlight === undefined ? true : options.singleFlight,
342343
submissions
343344
};
344345

src/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ declare module "solid-js/web" {
1111
matches?: OutputMatch[];
1212
cache?: Map<string, CacheEntry>;
1313
submission?: Submission<any, any>;
14+
dataOnly?: boolean | string[];
15+
data?: Record<string, any>;
16+
previousUrl?: string;
1417
}
1518
serverOnly?: boolean;
1619
}
@@ -160,6 +163,7 @@ export interface RouterContext {
160163
parsePath(str: string): string;
161164
beforeLeave: BeforeLeaveLifecycle;
162165
preloadRoute: (url: URL, preloadData: boolean) => void;
166+
singleFlight: boolean;
163167
submissions: Signal<Submission<any, any>[]>;
164168
}
165169

src/utils.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import type { MatchFilter, MatchFilters, Params, PathMatch, Route, SetParams } f
44
const hasSchemeRegex = /^(?:[a-z0-9]+:)?\/\//i;
55
const trimPathRegex = /^\/+|(\/)\/+$/g;
66
export const mockBase = "http://sr"
7-
export const redirectStatusCodes = new Set([204, 301, 302, 303, 307, 308]);
87

98
export function normalizePath(path: string, omitSlash: boolean = false) {
109
const s = path.replace(trimPathRegex, "$1");

0 commit comments

Comments
 (0)