diff --git a/.changeset/form-action-rename.md b/.changeset/form-action-rename.md new file mode 100644 index 00000000..40d5bed9 --- /dev/null +++ b/.changeset/form-action-rename.md @@ -0,0 +1,5 @@ +--- +"@solidjs/router": patch +--- + +Rename the router's `action` to `formAction` (and the `Action` type to `FormAction`): it is the form layer — submissions, `aria-busy`, no-JS posts, `.with()` — while `action` is solid-js's own primitive. `action` and `Action` stay as deprecated aliases with a dev warning until the next major. `useAction` is deprecated with no replacement: call the server function (or a core `action`) directly, since the router now applies revalidation and redirects on any server function call. diff --git a/.changeset/single-flight-at-mount.md b/.changeset/single-flight-at-mount.md new file mode 100644 index 00000000..16102b0f --- /dev/null +++ b/.changeset/single-flight-at-mount.md @@ -0,0 +1,5 @@ +--- +"@solidjs/router": patch +--- + +Single flight is now the router's, not the action layer's: a mounted router subscribes to the server function transport at mount (unless `singleFlight={false}`), so a server function called straight from an event handler gets its `X-Revalidate` keys, redirect and flight data applied without an action. Query reads, the flight consumer and actions now share one redirect/revalidation path, so action redirects resolve against the router base the same way query redirects do. diff --git a/README.md b/README.md index bf49cd75..35d3167b 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ Explore the official [documentation](https://docs.solidjs.com/solid-router) for - **Plain Anchors**: no link component — `` elements get `aria-current` and `data-active` automatically via compiler-claimed anchors, and `data-pending` with the opt-in `pendingLinks` plugin - **Universal Rendering**: one factory for browser, hash, memory, and server rendering; history adapters are imports, so unused ones never enter your bundle - **Preload Functions**: parallel data fetching following the render-as-you-fetch pattern, triggered eagerly by opt-in [link preloading](#preloading) -- **Data APIs with Caching**: `query` and `action` with deduplication, revalidation, single-flight mutations, and progressive enhancement — plus experimental `liveQuery` for keyed queries over live streams +- **Data APIs with Caching**: `query` and `formAction` with deduplication, revalidation, single-flight mutations, and progressive enhancement — plus experimental `liveQuery` for keyed queries over live streams - **Typed Search Params**: opt-in per-route [Standard Schema](https://github.com/standard-schema/standard-schema) validation — `search.page` is a `number`, not `"2"` ## Table of Contents @@ -412,7 +412,7 @@ Anything else callable with the args works too; wrapping is what gives dedupe, p Mutations reach these routes the way they reach any query, and the cost model is worth knowing: a swept server route is a server-side re-render plus its markup in the response, not a small data blob. From a server action, `redirect(url)` re-collects everything the target shows and a bare `reload()` everything the current page shows — server routes included, shell and layouts too. On a server-component-heavy page, name what a mutation actually changed instead. `Router.keysFor(url)` answers the query keys of the server routes a URL shows, root to leaf — the app's own keys, from pure matching, so it works in an action and never spells a key: ```ts -export const addComment = action(async (form: FormData) => { +export const addComment = formAction(async (form: FormData) => { "use server"; await db.comments.add(form); return reload({ revalidate: Router.keysFor(paths.stories(form.get("id"))) }); @@ -704,16 +704,16 @@ Live queries ride the router's existing machinery rather than adding their own: The callable carries the `query` conventions (`key`, `keyFor`) plus a reactive `status(...args)` read (`"idle" | "connecting" | "connected" | "reconnecting" | "closed"`) for surfacing connection state in UI. -### `action` +### `formAction` -A router action is _an action with a URL_ — Solid's mutation primitive plus URL addressability, submission tracking, and response handling. Data helpers come from the router; response helpers (`redirect`, `reload`) come from `@solidjs/web` — they're protocol-level and work without the router: +A form action is _an action with a URL_ — Solid's `action` primitive plus a form binding: URL addressability, submission tracking, `aria-busy`, and no-JS posts. Data helpers come from the router; response helpers (`redirect`, `reload`) come from `@solidjs/web` — they're protocol-level and work without the router: ```tsx -import { action } from "@solidjs/router"; +import { formAction } from "@solidjs/router"; import { redirect } from "@solidjs/web"; import { paths } from "./router"; -const updateUser = action(async (form: FormData) => { +const updateUser = formAction(async (form: FormData) => { await db.users.update(form.get("id"), form); throw redirect(paths.users(form.get("id"))); // typed paths work in redirects }); @@ -728,7 +728,7 @@ const updateUser = action(async (form: FormData) => { ``` -Actions only work with POST requests, so put `method="post"` on your form. Submitting forms get `aria-busy="true"` automatically from submit until the action's result is on screen — the mutation, then its revalidation or redirect, until that update commits — the same CSS story as links: +Form actions only work with POST requests, so put `method="post"` on your form. Submitting forms get `aria-busy="true"` automatically from submit until the action's result is on screen — the mutation, then its revalidation or redirect, until that update commits — the same CSS story as links: ```css form[aria-busy] button { @@ -741,18 +741,22 @@ Busy state belongs to the form's `action` URL, not the element: a form re-render Forms work without JavaScript: a real POST, a redirect back, and the result seeded into submission state through a one-shot flash cookie. Single-flight mutations are on by default — the mutation response carries the refreshed route data in the same round trip. -Delegation doesn't require the action's module on the client either. A form bound directly to a server action in a server-only module (a server component) renders a plain `action="/_server?id=...&args=..."` — a self-describing URL. On submit, the router synthesizes the invocation from it: the form data posts to that URL through the server-function transport, `.with()` arguments ride along in the query string, and submissions, `aria-busy`, redirects, revalidation, and single-flight data flow through the normal pipeline. The handler loads lazily on first such submit, so router-only bundles don't carry the data layer. The no-JS POST above remains the fallback only for clients that actually have no JavaScript. (Client-only actions — `action(fn, "name")` without `use server` — are their module's JS by definition and still require it on the client.) +Single flight is the router's, not the form action's: a mounted router applies any server function's revalidation keys, `redirect()`, `reload()`, and flight data, so a mutation called straight from an event handler needs no wrapper — call the server function (or a core `action` around it) directly. `singleFlight={false}` on the router opts out. + +> **Every POST server function call is treated as a mutation.** POST is the default for server functions. A call that declares no `revalidate` keys has the server re-collect the current page's data, and the client then revalidates every `query` on screen, the same default form actions have always had. Reads are exempt when they go through `query()`, which switches a server function to `GET` automatically, or when the function is declared with `GET(fn)`. A POST that isn't a mutation (logging, a search called on keystroke) should return `respond(value, { revalidate: "" })` (or `respond(null, { revalidate: "" })` with nothing to return) to touch nothing, or name the keys it did change. + +Delegation doesn't require the action's module on the client either. A form bound directly to a server action in a server-only module (a server component) renders a plain `action="/_server?id=...&args=..."` — a self-describing URL. On submit, the router synthesizes the invocation from it: the form data posts to that URL through the server-function transport, `.with()` arguments ride along in the query string, and submissions, `aria-busy`, redirects, revalidation, and single-flight data flow through the normal pipeline. The handler loads lazily on first such submit, so router-only bundles don't carry the data layer. The no-JS POST above remains the fallback only for clients that actually have no JavaScript. (Client-only form actions — `formAction(fn, "name")` without `use server` — are their module's JS by definition and still require it on the client.) For optimistic UI, attach owner-scoped hooks to the action and use Solid's optimistic primitives for rendered state: ```tsx import { createOptimisticStore } from "solid-js"; -import { action, query } from "@solidjs/router"; +import { formAction, query } from "@solidjs/router"; const getTodos = query(async () => fetchTodos(), "todos"); const [todos, setTodos] = createOptimisticStore(() => getTodos(), []); -const addTodo = action(async todo => { +const addTodo = formAction(async todo => { await saveTodo(todo); return { ok: true, todo }; }, "add-todo").onSubmit(todo => { @@ -762,37 +766,32 @@ const addTodo = action(async todo => { }); ``` -`onSubmit(...)` registers a listener in the current reactive owner — multiple components can register against the same action, and hooks are removed when their owner is disposed. `onSettled(...)` works the same way for observing completed submissions. A submission settles when its result is on screen: the hooks run, the record enters `useSubmissions`, and `aria-busy` clears once the action's update commits — after any revalidation or redirect it triggered, which may be later than the promise from `useAction` resolves. The hooks that run are the ones registered when the action finished: a hook whose owner that commit unmounts (the page a redirect leaves) still sees the submission, while a hook registered later, or removed with its owner before then, does not. +`onSubmit(...)` registers a listener in the current reactive owner — multiple components can register against the same action, and hooks are removed when their owner is disposed. `onSettled(...)` works the same way for observing completed submissions. A submission settles when its result is on screen: the hooks run, the record enters `useSubmissions`, and `aria-busy` clears once the action's update commits — after any revalidation or redirect it triggered, which may be later than the promise from a direct call resolves. The hooks that run are the ones registered when the action finished: a hook whose owner that commit unmounts (the page a redirect leaves) still sees the submission, while a hook registered later, or removed with its owner before then, does not. The preferred pattern is returning values and letting the client interpret the result; thrown errors are still captured on `Submission.error` as an escape hatch. -Actions have a `with` method (like `bind`) for typed arguments instead of hidden form fields: +Form actions have a `with` method (like `bind`) for typed arguments instead of hidden form fields: ```tsx -const deleteTodo = action(api.deleteTodo); +const deleteTodo = formAction(api.deleteTodo);
; ``` -Since form actions serialize to string attributes that must match across SSR, actions that aren't server functions need a stable name: `action(fn, "my-action")`. - -### `useAction` +Since form actions serialize to string attributes that must match across SSR, form actions that aren't server functions need a stable name: `formAction(fn, "my-action")`. -Call an action directly instead of through a form — this is how the router context is captured. Outside a form you can pass typed data instead of `FormData`, but this requires client-side JavaScript and is not progressively enhanceable: +### `useAction` (deprecated) -```tsx -const submit = useAction(myAction); -submit(...args); -``` +Deprecated, with no replacement: outside a form, call the server function (or a core `action`) directly — the router applies its revalidation and redirect either way. Still works, with a dev warning, until the next major. ### `useSubmissions` Returns settled submission records for an action — the durable history layer, not in-flight state. Useful for reading completed results, clearing old submissions, retrying, or replaying settled errors: ```tsx -const submissions = useSubmissions(action, input => filter(input)); +const submissions = useSubmissions(addTodo, input => filter(input)); const latest = submissions.at(-1); // { input, result?, error, url, clear(), retry() } ``` @@ -1114,6 +1113,8 @@ Route props map 1:1 onto definition keys (`path`, `component`, `preload`, `match - `redirect` / `reload` → import from `@solidjs/web`; they're protocol-level and work without the router - `json(data, init)` → `respond(data, init)` from `@solidjs/web` - `cache` (deprecated alias) → `query` +- `action` → `formAction` (`Action` → `FormAction`); the old names remain as deprecated aliases with a dev warning. `action` is solid-js's own primitive. +- `useAction` → deprecated with no replacement; call the server function or a core `action` directly - `RouterContext`'s `isRouting` / `pendingTarget` → `useIsRouting()`; for the in-flight destination, the [`isPending`/`latest` recipe](#useisrouting) ### Data APIs (Solid 2) @@ -1138,7 +1139,7 @@ const submitting = useSubmission(addTodo); // 1.0 — optimistic primitives own in-flight state const [todos, setTodos] = createOptimisticStore(() => getTodos(), []); -const addTodo = action(saveTodo).onSubmit(todo => +const addTodo = formAction(saveTodo).onSubmit(todo => setTodos(items => { items.push({ ...todo, pending: true }); }) diff --git a/src/data/action.ts b/src/data/action.ts index 156ac7f9..5a8ad4d1 100644 --- a/src/data/action.ts +++ b/src/data/action.ts @@ -4,32 +4,31 @@ import { createMemo, onCleanup, onSettled, - getOwner + getOwner, + DEV } from "solid-js"; -import { isResponseEnvelope, isServer, REVALIDATE_HEADER, type JSX } from "@solidjs/web"; +import { isResponseEnvelope, isServer, type JSX } from "@solidjs/web"; import { createServerReference, - decodeRedirectHeaderValue, decodeResponsePayload, - parseServerFunctionActionUrl, - REDIRECT_HEADER, - subscribeFlightData + parseServerFunctionActionUrl } from "@solidjs/web/server-functions"; import { setFormClaimHandler } from "../claims.js"; -import { provideFlashDecoder, provideFlightConsumer, useRouter } from "../routing.js"; +import { provideFlashDecoder, useRouter } from "../routing.js"; import { setRouterFormHandler } from "./events.js"; -import type { RouterContext, Submission, Navigator, NarrowResponse } from "../types.js"; +import type { RouterContext, Submission, NarrowResponse } from "../types.js"; import { mockBase, setFunctionName } from "../utils.js"; -import { - cacheKeyOp, - deliverFlightData, - hashKey, - readRevalidateKeys, - revalidate, - query -} from "./query.js"; +import { applyServerResponse, flightApplications, hashKey } from "./query.js"; -export type Action, U, V = T> = (T extends [FormData | URLSearchParams] | [] +/** + * A router form action: the form layer over a mutation. Bind it to a + * `
` (or a `formaction` button) for submissions, + * `aria-busy`, no-JS posts, redirects and revalidation; `.with()` binds + * leading arguments into the URL. + */ +export type FormAction, U, V = T> = (T extends + | [FormData | URLSearchParams] + | [] ? JSX.SerializableAttributeValue : unknown) & ((...vars: T) => Promise>) & { @@ -37,19 +36,19 @@ export type Action, U, V = T> = (T extends [FormData | URLS with( this: (this: any, ...args: [...A, ...B]) => Promise>, ...args: A - ): Action; - onSubmit(hook: (...args: V extends Array ? V : T) => void): Action; + ): FormAction; + onSubmit(hook: (...args: V extends Array ? V : T) => void): FormAction; onSettled( hook: (submission: Submission ? V : T, NarrowResponse>) => void - ): Action; + ): FormAction; }; -type ActionFactory = { - , U = void>(fn: (...args: T) => Promise, name?: string): Action; +type FormActionFactory = { + , U = void>(fn: (...args: T) => Promise, name?: string): FormAction; , U = void>( fn: (...args: T) => Promise, options?: { name?: string } - ): Action; + ): FormAction; }; type InternalAction, U, V = T> = { @@ -147,7 +146,7 @@ function claimBusyForm(form: HTMLFormElement) { showBusy(form); } -export const actions = /* #__PURE__ */ new Map>(); +export const actions = /* #__PURE__ */ new Map>(); /** * The document-delegation submit handler for router actions. Lives here — @@ -197,7 +196,7 @@ export function handleFormAction(evt: SubmitEvent, router: RouterContext, action * the rendered arguments, so the submission runs through that action's * submit and settled hooks and is recorded under its base. */ -function findAction(url: string): Action | undefined { +function findAction(url: string): FormAction | undefined { const handler = actions.get(url); if (handler) return handler; const query = url.indexOf("?"); @@ -229,7 +228,7 @@ function findAction(url: string): Action | undefined { */ function createServerFormAction( url: string -): Action<[FormData | URLSearchParams], unknown> | undefined { +): FormAction<[FormData | URLSearchParams], unknown> | undefined { const id = parseServerFunctionActionUrl(url); if (!id) return undefined; // typecheck resolves the server half of the dual module; this path only @@ -276,8 +275,8 @@ export function submitServerForm( // the first action creation. Not an import side effect — with // `sideEffects: false`, module evaluation only happens when action() is // actually used, which is precisely when the wiring is wanted: no action in -// the graph means no form interception, no single-flight subscription (the -// server is never asked to collect), and no flash cookies to decode. On the +// the graph means no form interception and no flash cookies to decode +// (single flight is the router's own, subscribed at mount). On the // server, actions are created at module scope, so the flash decoder is // always installed before useSubmission can read the submissions signal. let integrationsInstalled = false; @@ -300,12 +299,11 @@ function installRouterIntegrations() { } else { setRouterFormHandler(handleFormAction); setFormClaimHandler(claimBusyForm); - provideFlightConsumer(setupFlightDataConsumer); } } export function useSubmissions, U, V>( - fn: Action, + fn: FormAction, filter?: (input: V) => boolean ): Submission>[] { const router = useRouter(); @@ -330,15 +328,22 @@ export function useSubmissions, U, V>( }); } -export function useAction, U, V>(action: Action) { +/** + * @deprecated Call the server function (or a core `action`) directly: the + * router applies a server function's revalidation and redirect on any call, + * and a form action is meant to be bound to a form. Will be removed in the + * next major. + */ +export function useAction, U, V>(action: FormAction) { + DEV && warnUseAction(); const r = useRouter(); - return (...args: Parameters>) => action.apply({ r }, args); + return (...args: Parameters>) => action.apply({ r }, args); } function actionImpl, U = void>( fn: (...args: T) => Promise, options: string | { name?: string } = {} -): Action { +): FormAction { async function invoke( this: { r: RouterContext; f?: HTMLFormElement }, variables: T, @@ -348,9 +353,9 @@ function actionImpl, U = void>( const form = this.f; const submitHooks = current[submitHooksSymbol]; const settledHooks = current[settledHooksSymbol]; - // Single-flight opt-in is no longer per call: the router's registered - // flight-data consumer (see setupFlightDataConsumer) makes the transport - // send the request header itself, so the mutation is just called. + // Single-flight opt-in is no longer per call: the router's flight-data + // consumer (setupFlightDataConsumer in query.ts) makes the transport send + // the request header itself, so the mutation is just called. const runMutation = () => fn(...variables); // The busy release, the submission record and the settled hooks wait for // the action's transition to COMMIT, not just its body: the body's final @@ -428,7 +433,7 @@ function actionImpl, U = void>( // the release of the caller's optimistic writes commit as one frame (#619). const response = applyResponse( read, - router.navigatorFactory(), + router, flightApplications !== flightApplicationsBefore ); finish(response); @@ -495,11 +500,24 @@ function actionImpl, U = void>( const wrapped = toAction( invoke as InternalAction[typeof invokeSymbol], url - ) as Action; + ) as FormAction; if (name) setFunctionName(wrapped, name); return wrapped; } -export const action = actionImpl as ActionFactory; +/** + * Creates a form action from a mutation (a server function or any async + * function). See {@link FormAction}. + */ +export const formAction = actionImpl as FormActionFactory; + +let useActionWarned = false; +function warnUseAction() { + if (useActionWarned) return; + useActionWarned = true; + console.warn( + "[@solidjs/router] `useAction` is deprecated and will be removed in the next major; call the server function or a core `action` directly, and bind form actions to forms." + ); +} function toAction, U, V = T>( invoke: InternalAction[typeof invokeSymbol], @@ -508,7 +526,7 @@ function toAction, U, V = T>( base = url, submitHooks = new Map void>(), settledHooks = new Map) => void>() -): Action { +): FormAction { const fn = function (this: { r: RouterContext; f?: HTMLFormElement }, ...args: T) { return invoke.call(this, [...boundArgs, ...args], fn); } as InternalAction; @@ -556,13 +574,13 @@ function toAction, U, V = T>( fn[invokeSymbol] = invoke; installRouterIntegrations(); if (!isServer) { - actions.set(url, fn as unknown as Action); + actions.set(url, fn as unknown as FormAction); // Only remove the registration if it still belongs to this instance — // a re-created action (e.g. a new `.with()` binding after revalidation) // may have registered itself under the same URL since. getOwner() && onCleanup(() => actions.get(url) === (fn as unknown) && actions.delete(url)); } - return fn as unknown as Action; + return fn as unknown as FormAction; } const hashString = (s: string) => @@ -584,97 +602,6 @@ async function settleActionResult(result: T | Promise | AsyncIterable) return result as T; } -// Invocation count of the flight-data consumer. An action compares it across -// its mutation call to learn whether the transport already applied this -// response's metadata (and so the default revalidation pass must not run -// again and wipe the freshly seeded cache). -let flightApplications = 0; - -// The statuses fetch follows (Fetch §2.2.3) — the set the server masks into -// the redirect carrier for scripted calls, and the set a locally-produced -// redirect() envelope wears for real. -const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]); - -/** - * Registers the router as the single-flight consumer of the server function - * transport. Subscribing is the opt-in: while registered, the transport - * sends the `X-Single-Flight` request header on mutations and delivers the - * folded payload here — fresh route data is seeded into the `query` cache - * and the envelope metadata (redirect `Location`, `X-Revalidate` keys) is - * applied, all before the action sees its plain return value. Called by the - * Router component on the client unless `singleFlight={false}`, which now - * simply means "never subscribe" — no consumer, no request header, no - * collection work on the server. Returns the unsubscribe function. - */ -export function setupFlightDataConsumer(router: RouterContext) { - return subscribeFlightData>((data, { response }) => { - flightApplications++; - return applyResponseMetadata(response, router.navigatorFactory(), data); - }); -} - -/** - * Applies a server function response's integration metadata: `X-Revalidate` - * keys invalidate, the redirect carrier navigates (soft when same-origin, - * hard otherwise), flight data seeds the query cache, and matching entries - * revalidate. Shared by the flight-data consumer and the action response - * path (which still sees metadata-bearing responses when no flight data was - * collected). - */ -function applyResponseMetadata( - metadata: Response | undefined, - navigate: Navigator, - flightData?: Record -) { - let keys: string[] | undefined; - if (metadata) { - if (metadata.headers.has(REVALIDATE_HEADER)) - keys = readRevalidateKeys(metadata.headers.get(REVALIDATE_HEADER)!); - // The carrier delivers the target RESOLVED to an absolute url - // (solidjs/solid#3102), so the soft/hard split is a real origin - // comparison — never a guess from how the author spelled the target, - // which sent `redirect("/")` and `redirect(new URL("/", url).href)` - // down different navigation paths (solidjs/solid#3107). A redirect - // produced locally (a client-side action's `redirect()`) never crossed - // the wire, so no carrier was attached: it is the real 3xx with its - // Location, resolved against the page it runs in. A `Location` on any - // other status is the author's data (a 201's created-at) and never - // navigates. Same-origin targets navigate softly under the router; - // anything else leaves the app, so the document goes with it. - // `replace` matches what HTTP gives a form post: the target takes the - // submission's place in history rather than stacking on it. - const carried = decodeRedirectHeaderValue(metadata.headers.get(REDIRECT_HEADER)); - const local = - !carried && REDIRECT_STATUSES.has(metadata.status) && metadata.headers.get("Location"); - const target = carried - ? new URL(carried.url) - : local - ? new URL(local, window.location.href) - : undefined; - if (target) { - if (target.origin === window.location.origin) { - navigate(target.pathname + target.search + target.hash, { replace: true }); - } else { - window.location.href = target.href; - } - } - } - // invalidate - cacheKeyOp(keys, entry => (entry[0] = 0)); - // set cache — and fan the payload out to other keyed stores (live query - // channels adopt delivered values instead of reconnecting) - if (flightData) { - Object.keys(flightData).forEach(k => query.set(k, flightData[k])); - deliverFlightData(flightData); - } - // trigger revalidation inside the same transition as the navigation, so - // the redirect commits atomically with fresh data: surviving consumers - // (shared layouts) the flight payload didn't seed refetch and hold the - // commit rather than painting stale and updating after. Seeded entries - // are fresh again by now, so the sweep re-reads them from cache. - revalidate(keys, false); -} - type ReadResponse = | { error: unknown } | { data?: any; flightData?: Record; metadata?: Response }; @@ -707,13 +634,13 @@ async function readResponse(response: unknown, error: boolean): Promise { + if (!DEV) return formAction; + let warned = false; + const wrapped = function (this: unknown, ...args: any[]) { + if (!warned) { + warned = true; + console.warn( + "[@solidjs/router] `action` is deprecated and will be removed in the next major; use `formAction` instead." + ); + } + return (formAction as any).apply(this, args); + }; + return wrapped as unknown as typeof formAction; +})(); + +/** @deprecated Renamed to `FormAction`. Will be removed in the next major. */ +export type Action, U, V = T> = FormAction; diff --git a/src/data/index.ts b/src/data/index.ts index 2f1ff1ea..e0281f03 100644 --- a/src/data/index.ts +++ b/src/data/index.ts @@ -1,3 +1,4 @@ -export { action, useSubmissions, useAction, type Action } from "./action.js"; +export { formAction, useSubmissions, useAction, type FormAction } from "./action.js"; +export { action, type Action } from "./deprecated.js"; export { query, revalidate, type CachedFunction } from "./query.js"; export { liveQuery, type LiveFunction, type LiveQueryStatus } from "./liveQuery.js"; diff --git a/src/data/query.ts b/src/data/query.ts index 0097bbc7..2e595bbf 100644 --- a/src/data/query.ts +++ b/src/data/query.ts @@ -34,9 +34,13 @@ import { // dependency-free bindings off a `sideEffects: false` entry, so they // tree-shake to a few hundred bytes without dragging the codec in (the same // bindings action.ts already imports statically). -import { decodeRedirectHeaderValue, REDIRECT_HEADER } from "@solidjs/web/server-functions"; +import { + decodeRedirectHeaderValue, + REDIRECT_HEADER, + subscribeFlightData +} from "@solidjs/web/server-functions"; import { useRouter, getIntent, getInPreloadFn } from "../routing.js"; -import type { CacheEntry, NarrowResponse } from "../types.js"; +import type { CacheEntry, Navigator, NarrowResponse, RouterContext } from "../types.js"; import { isUnderBase } from "../utils.js"; /** @@ -372,65 +376,14 @@ export function query any>(fn: T, name: string): Cac } } - let url = v.headers.get(LocationHeader); - let carried: ReturnType; - - // The transport masks redirects to 200 and carries their target in a - // header. A local Location only redirects on an actual redirect status. - if (!isServer) { - carried = decodeRedirectHeaderValue(v.headers.get(REDIRECT_HEADER)); - url = carried?.url ?? (REDIRECT_STATUSES.has(v.status) ? url : null); - } - - if (url !== null) { - // invalidate the redirect's revalidation keys before navigating so - // the destination's preloads see the miss and fetch fresh (#580 thread). - // A read that redirects declares nothing by default (unlike an - // action), so only a header present here sweeps: named keys their - // matches, `*` everything. - const declared = !isServer ? v.headers.get(REVALIDATE_HEADER) : null; - const keys = declared !== null ? readRevalidateKeys(declared) : undefined; - declared !== null && cacheKeyOp(keys, entry => (entry[0] = 0)); - - if (!isServer) { - const target = new URL(url, window.location.href); - if (navigate && target.origin === window.location.origin) { - // Carried redirects and local leading slashes keep their - // router-base meaning. Other URLs need the integration's parser - // (e.g. hash history) without applying the base again. - const relativeToBase = !!carried || (url[0] === "/" && url[1] !== "/"); - const resolved = target.pathname + target.search + target.hash; - const path = carried - ? resolved - : relativeToBase - ? url - : router!.parsePath(resolved); - const rendered = new URL(router!.renderPath(path), window.location.href); - // Resolved URLs must stay within the router base and round-trip - // to the same document URL, including with hash history. - if ( - relativeToBase || - (isUnderBase(path.split(/[?#]/, 1)[0], router!.base.path()) && - rendered.href === target.href) - ) { - navigate(path, { replace: true, resolve: relativeToBase }); - } else window.location.href = target.href; - } else window.location.href = target.href; - } else if (navigate && url.startsWith("/")) navigate(url, { replace: true }); - else if (e) e.response.status = 302; - - // sweep the live signals inside the same transition as the - // navigation: surviving consumers (shared layouts) refetch and - // hold the commit, so the destination never paints stale data - declared !== null && revalidate(keys, false); - - // Hold the read pending on the client: the navigation unmounts this - // consumer, and resolving `undefined` instead hands a missing value - // to whatever renders before the transition commits (a downstream - // `.map()` crashes). The server keeps resolving — its render can't - // await a navigation and the 302 discards the body anyway. + // A redirect navigates (after sweeping any keys it declares), and + // the read stays pending on the client: the navigation unmounts + // this consumer, and resolving `undefined` instead hands a missing + // value to whatever renders before the transition commits (a + // downstream `.map()` crashes). The server keeps resolving — its + // render can't await a navigation and the 302 discards the body. + if (applyServerResponse(v, router, navigate, false, e)) return isServer ? undefined : new Promise(() => {}); - } if (hasEnveloped) v = enveloped; else if (v.body) { @@ -498,6 +451,98 @@ query.clear = () => getCache().clear(); * host-independent spelling of "all", for hosts whose default is narrower * than this router's. */ +/** + * Applies a server function response's routing metadata, shared by query + * reads and the single-flight consumer. The redirect carrier (or a real + * redirect status's `Location`) navigates — softly within the router base, + * otherwise as a document load — and `X-Revalidate` keys are invalidated + * before the navigation (so the destination's preloads miss and fetch + * fresh) and swept after it, inside the same transition, so surviving + * consumers refetch and hold the commit. Flight data, when delivered, seeds + * the cache between the two. A read declares nothing by default; a mutation + * (`mutation`) with no header revalidates everything. Returns whether the + * response redirected. + */ +export function applyServerResponse( + v: Response | undefined, + router: RouterContext | undefined, + navigate: Navigator | undefined, + mutation: boolean, + e?: { response: { status?: number } }, + flightData?: Record +): boolean { + let url = v ? v.headers.get(LocationHeader) : null; + let carried: ReturnType; + let declared: string | null = null; + // The transport masks redirects to 200 and carries their target in a + // header. A local Location only redirects on an actual redirect status. + if (v && !isServer) { + carried = decodeRedirectHeaderValue(v.headers.get(REDIRECT_HEADER)); + url = carried?.url ?? (REDIRECT_STATUSES.has(v.status) ? url : null); + declared = v.headers.get(REVALIDATE_HEADER); + } + if (url === null && !mutation) return false; + const sweep = mutation || declared !== null; + const keys = declared !== null ? readRevalidateKeys(declared) : undefined; + sweep && cacheKeyOp(keys, entry => (entry[0] = 0)); + if (url !== null) { + if (!isServer) { + const target = new URL(url, window.location.href); + if (navigate && target.origin === window.location.origin) { + // Carried redirects and local leading slashes keep their router-base + // meaning. Other URLs need the integration's parser (e.g. hash + // history) without applying the base again. + const relativeToBase = !!carried || (url[0] === "/" && url[1] !== "/"); + const resolved = target.pathname + target.search + target.hash; + const path = carried ? resolved : relativeToBase ? url : router!.parsePath(resolved); + const rendered = new URL(router!.renderPath(path), window.location.href); + // Resolved URLs must stay within the router base and round-trip to + // the same document URL, including with hash history. + if ( + relativeToBase || + (isUnderBase(path.split(/[?#]/, 1)[0], router!.base.path()) && + rendered.href === target.href) + ) { + navigate(path, { replace: true, resolve: relativeToBase }); + } else window.location.href = target.href; + } else window.location.href = target.href; + } else if (navigate && url.startsWith("/")) navigate(url, { replace: true }); + else if (e) e.response.status = 302; + } + // seed the cache — and fan the payload out to other keyed stores (live + // query channels adopt delivered values instead of reconnecting) + if (flightData) { + Object.keys(flightData).forEach(k => query.set(k, flightData[k])); + deliverFlightData(flightData); + } + sweep && revalidate(keys, false); + return url !== null; +} + +// Invocation count of the flight-data consumer. An action compares it across +// its mutation call to learn whether the transport already applied this +// response's metadata (and so its default revalidation pass must not run +// again and wipe the freshly seeded cache). +export let flightApplications = 0; + +/** + * Registers the router as the single-flight consumer of the server function + * transport. Subscribing is the opt-in: while registered, the transport + * sends the `X-Single-Flight` request header on mutations and delivers the + * folded payload here — fresh route data is seeded into the `query` cache + * and the envelope metadata (redirect, `X-Revalidate` keys) is applied + * before the caller sees its plain return value, whether the call came from + * an action or straight from an event handler. The Router subscribes at + * mount on the client unless `singleFlight={false}`. Returns the + * unsubscribe function. + */ +export function setupFlightDataConsumer(router: RouterContext) { + return subscribeFlightData>((data, { response }) => { + flightApplications++; + applyServerResponse(response, router, router.navigatorFactory(), true, undefined, data); + }); +} + const REVALIDATE_ALL = "*"; /** diff --git a/src/routers/factory.tsx b/src/routers/factory.tsx index 2cc369fc..33cfc8cb 100644 --- a/src/routers/factory.tsx +++ b/src/routers/factory.tsx @@ -19,6 +19,7 @@ import { getRequestEvent, isServer } from "@solidjs/web"; import type { JSX } from "@solidjs/web"; import { setupLinkClaims, setupServerLinkClaims } from "../claims.js"; import { setupNativeEvents } from "../data/events.js"; +import { setupFlightDataConsumer } from "../data/query.js"; import { createPathsProxy, HREF } from "../paths.js"; import type { RoutePaths } from "../paths.js"; import { serverRouteOf } from "../serverRouteShared.js"; @@ -27,7 +28,6 @@ import { createRouterContext, getRouteMatches, mergeParams, - registerFlightRouter, resolveLocationWrite, RouterContextObj, trackLazySubtrees, @@ -541,7 +541,7 @@ export function createRouter( actionBase: config.actionBase })(routerState); setupLinkClaims(routerState, config.explicitLinks, config.links); - if (routerState.singleFlight) onCleanup(registerFlightRouter(routerState)); + if (routerState.singleFlight) onCleanup(setupFlightDataConsumer(routerState)); restoration && restoration.create(); } else if (!renderPath) { // hash routing renders `#/path` hrefs, which all resolve to the server's one path diff --git a/src/routing.ts b/src/routing.ts index 41164824..8034e637 100644 --- a/src/routing.ts +++ b/src/routing.ts @@ -724,36 +724,6 @@ function createLocation( }; } -/** - * Rendezvous between the router and the data layer's single-flight consumer. - * The Router registers itself at mount (unless `singleFlight={false}`); the - * action side provides the consumer factory when the first action is created - * (see data/action.ts). Whichever side arrives first waits for the other, so - * an action module loaded lazily (a code-split route) still attaches to the - * already-mounted router — and a router-only app, where no action ever - * loads, never subscribes to the transport, so the server is never asked to - * collect. - */ -let flightConsumerFactory: ((router: RouterContext) => () => void) | undefined; -const flightRouters = new Map void) | undefined>(); - -export function registerFlightRouter(router: RouterContext): () => void { - flightRouters.set(router, flightConsumerFactory && flightConsumerFactory(router)); - return () => { - const unsubscribe = flightRouters.get(router); - flightRouters.delete(router); - unsubscribe && unsubscribe(); - }; -} - -export function provideFlightConsumer(factory: (router: RouterContext) => () => void): void { - if (flightConsumerFactory) return; - flightConsumerFactory = factory; - for (const [router, unsubscribe] of flightRouters) { - if (!unsubscribe) flightRouters.set(router, factory(router)); - } -} - /** * The flash-cookie codec, provided by the action side (data/action.ts) so * the router core never carries it: the core consumes the cookie eagerly diff --git a/test/action-commit-release.spec.tsx b/test/action-commit-release.spec.tsx index 80014293..6dff5904 100644 --- a/test/action-commit-release.spec.tsx +++ b/test/action-commit-release.spec.tsx @@ -13,7 +13,7 @@ import { action, createMemo, createRoot, createSignal, For, Loading, Show } from import { vi } from "vitest"; import { claimElementTree, redirect, render } from "@solidjs/web"; import { - action as routerAction, + formAction as routerAction, createRouter, memoryHistory, query, diff --git a/test/data/action.spec.ts b/test/data/action.spec.ts index dd94abed..1b270316 100644 --- a/test/data/action.spec.ts +++ b/test/data/action.spec.ts @@ -1,13 +1,13 @@ import { createEffect, createRoot, flush } from "solid-js"; import { vi } from "vitest"; import { - action, + formAction, useAction, useSubmissions, actions, handleFormAction, submitServerForm, - type Action + type FormAction } from "../../src/data/action.js"; import type { RouterContext } from "../../src/types.js"; import { createMockRouter } from "../helpers.js"; @@ -28,7 +28,6 @@ let mockRouterContext: RouterContext; vi.mock("../../src/routing.js", () => ({ useRouter: () => mockRouterContext, - provideFlightConsumer: vi.fn(), provideFlashDecoder: vi.fn(), createRouterContext: () => createMockRouter(), RouterContextObj: {}, @@ -53,7 +52,7 @@ describe("action", () => { }); test("should create an action function with `url` property", () => { - const testAction = action(async (data: string) => { + const testAction = formAction(async (data: string) => { return `processed: ${data}`; }, "test-action"); @@ -63,7 +62,7 @@ describe("action", () => { test("should create action with auto-generated hash when no `name` provided", () => { const testFn = async (data: string) => `result: ${data}`; - const testAction = action(testFn); + const testAction = formAction(testFn); expect(testAction.url).toMatch(/^https:\/\/action\/-?\d+$/); expect((testAction as any).name).toMatch(/^-?\d+$/); @@ -71,7 +70,7 @@ describe("action", () => { test("should use it as `name` when `options` are provided as a string", () => { const testFn = async (data: string) => `result: ${data}`; - const testAction = action(testFn, "test-action"); + const testAction = formAction(testFn, "test-action"); expect(testAction.url).toMatch("https://action/test-action"); expect((testAction as any).name).toBe("test-action"); @@ -79,21 +78,21 @@ describe("action", () => { test("should use `name` when provided in object options", () => { const testFn = async (data: string) => `result: ${data}`; - const testAction = action(testFn, { name: "test-action" }); + const testAction = formAction(testFn, { name: "test-action" }); expect(testAction.url).toMatch("https://action/test-action"); expect((testAction as any).name).toBe("test-action"); }); test("should register action in actions map", () => { - const testAction = action(async () => "result", "register-test"); + const testAction = formAction(async () => "result", "register-test"); expect(actions.has(testAction.url)).toBe(true); expect(actions.get(testAction.url)).toBe(testAction); }); test("disposing an older owner should not unregister a newer action with the same URL", () => { - const base = action(async (id: string, data: string) => `${id}: ${data}`, "cleanup-test"); + const base = formAction(async (id: string, data: string) => `${id}: ${data}`, "cleanup-test"); let disposeFirst!: () => void; let first!: ReturnType; @@ -117,10 +116,10 @@ describe("action", () => { test("disposing the current owner should unregister its action", () => { let dispose!: () => void; - let registered!: Action<[], string>; + let registered!: FormAction<[], string>; createRoot(d => { dispose = d; - registered = action(async () => "result", "self-cleanup-test"); + registered = formAction(async () => "result", "self-cleanup-test"); }); expect(actions.get(registered.url)).toBe(registered); @@ -129,7 +128,7 @@ describe("action", () => { }); test("should support `.with` method for currying arguments", () => { - const baseAction = action(async (prefix: string, data: string) => { + const baseAction = formAction(async (prefix: string, data: string) => { return `${prefix}: ${data}`; }, "with-test"); @@ -140,7 +139,7 @@ describe("action", () => { }); test("a chained `.with` url carries every bound argument", () => { - const move = action(async (from: string, to: string, data: string) => data, "chained-with"); + const move = formAction(async (from: string, to: string, data: string) => data, "chained-with"); const fromA = move.with("a").with("x"); const fromB = move.with("b").with("x"); @@ -154,7 +153,7 @@ describe("action", () => { // actions are invoked outside `createRoot` — as of Solid 2.0.0-beta.18 calling an // action inside an owned scope throws ACTION_CALLED_IN_OWNED_SCOPE in dev test("should execute action and create submission", async () => { - const testAction = action(async (data: string) => { + const testAction = formAction(async (data: string) => { return `processed: ${data}`; }, "execute-test"); @@ -176,7 +175,7 @@ describe("action", () => { }); test("should still record thrown action errors for legacy usage", async () => { - const errorAction = action(async () => { + const errorAction = formAction(async () => { throw new Error("Test error"); }, "error-test"); @@ -194,7 +193,7 @@ describe("action", () => { test("should support `onSettled` callback", async () => { const onSettled = vi.fn(); - const testAction = action(async (data: string) => `result: ${data}`, { + const testAction = formAction(async (data: string) => `result: ${data}`, { name: "callback-test" }).onSettled(onSettled); @@ -213,7 +212,9 @@ describe("action", () => { // worth showing or retrying (a result or an error) enter the submissions list test("onSettled fires for a void action without recording a submission", async () => { const onSettled = vi.fn(); - const voidAction = action(async () => {}, { name: "void-settled-test" }).onSettled(onSettled); + const voidAction = formAction(async () => {}, { name: "void-settled-test" }).onSettled( + onSettled + ); const boundAction = useAction(voidAction); await boundAction(); @@ -236,7 +237,7 @@ describe("action", () => { // a real redirect: 302 + Location, what redirect() produces — a // Location on a non-redirect status is data and never navigates - const redirectAction = action( + const redirectAction = formAction( async () => new Response(null, { status: 302, headers: { Location: "/next" } }), { name: "redirect-settled-test" } ).onSettled(onSettled); @@ -244,7 +245,7 @@ describe("action", () => { const boundAction = useAction(redirectAction); await boundAction(); - expect(navigate).toHaveBeenCalledWith("/next", { replace: true }); + expect(navigate).toHaveBeenCalledWith("/next", { replace: true, resolve: true }); expect(onSettled).toHaveBeenCalledTimes(1); expect(mockRouterContext.submissions[0]()).toHaveLength(0); }); @@ -252,7 +253,7 @@ describe("action", () => { test("should run onSubmit hook before mutation settles", async () => { const order: string[] = []; let current = "initial"; - const testAction = action(async (next: string) => { + const testAction = formAction(async (next: string) => { order.push("mutation"); expect(current).toBe(next); return next; @@ -270,7 +271,7 @@ describe("action", () => { test("should preserve onSubmit hook for curried actions", async () => { const onSubmit = vi.fn(); - const baseAction = action( + const baseAction = formAction( async (prefix: string, value: string) => `${prefix}:${value}`, "curried-on-submit" ).onSubmit(onSubmit); @@ -284,7 +285,7 @@ describe("action", () => { return createRoot(async () => { const solid = (await import("solid-js")) as any; const [value, setValue] = solid.createOptimistic("initial"); - const testAction = action(async (next: string) => { + const testAction = formAction(async (next: string) => { // A28 (5): an optimistic write becomes the override at the flush that // carries it — the mutation's synchronous plain read, in the same tick // as onSubmit's write, still sees the flushed value. The scheduler's @@ -305,7 +306,7 @@ describe("action", () => { test("should notify all live onSubmit registrations for an action", async () => { const calls: string[] = []; - const testAction = action(async (value: string) => value, "shared-on-submit"); + const testAction = formAction(async (value: string) => value, "shared-on-submit"); createRoot(dispose => { testAction.onSubmit(value => calls.push(`one:${value}`)); @@ -324,7 +325,7 @@ describe("action", () => { test("should clean up onSubmit registrations with owner disposal", async () => { const calls: string[] = []; - const testAction = action(async (value: string) => value, "cleanup-on-submit"); + const testAction = formAction(async (value: string) => value, "cleanup-on-submit"); let disposeFirst!: () => void; let disposeSecond!: () => void; @@ -349,7 +350,7 @@ describe("action", () => { test("should notify all live onSettled registrations for an action", async () => { const calls: string[] = []; - const testAction = action(async (value: string) => value, "shared-on-settled"); + const testAction = formAction(async (value: string) => value, "shared-on-settled"); createRoot(dispose => { testAction.onSettled(submission => calls.push(`one:${submission.result}`)); @@ -368,7 +369,7 @@ describe("action", () => { test("should clean up onSettled registrations with owner disposal", async () => { const calls: string[] = []; - const testAction = action(async (value: string) => value, "cleanup-on-settled"); + const testAction = formAction(async (value: string) => value, "cleanup-on-settled"); let disposeFirst!: () => void; let disposeSecond!: () => void; @@ -399,7 +400,7 @@ describe("useSubmissions", () => { test("should return submissions for specific action", () => { return createRoot(() => { - const testAction = action(async () => "result", "submissions-test"); + const testAction = formAction(async () => "result", "submissions-test"); mockRouterContext.submissions[1](submissions => [ ...submissions, @@ -431,7 +432,7 @@ describe("useSubmissions", () => { test("should filter submissions when filter function provided", () => { return createRoot(() => { - const testAction = action(async (data: string) => data, "filter-test"); + const testAction = formAction(async (data: string) => data, "filter-test"); mockRouterContext.submissions[1](submissions => [ ...submissions, @@ -462,7 +463,7 @@ describe("useSubmissions", () => { test("should return an empty array when no submissions match", () => { return createRoot(() => { - const testAction = action(async () => "result", "no-pending-test"); + const testAction = formAction(async () => "result", "no-pending-test"); const submissions = useSubmissions(testAction, () => false); expect(submissions).toHaveLength(0); @@ -471,7 +472,7 @@ describe("useSubmissions", () => { test("should allow reading the latest submission with at(-1)", () => { return createRoot(() => { - const testAction = action(async () => "result", "latest-test"); + const testAction = formAction(async () => "result", "latest-test"); mockRouterContext.submissions[1](submissions => [ ...submissions, @@ -502,7 +503,7 @@ describe("useSubmissions", () => { test("should return undefined from at(-1) when no submissions exist", () => { return createRoot(() => { - const testAction = action(async () => "result", "empty-latest-test"); + const testAction = formAction(async () => "result", "empty-latest-test"); const submission = useSubmissions(testAction).at(-1); expect(submission).toBeUndefined(); @@ -511,7 +512,7 @@ describe("useSubmissions", () => { test("should allow filtering before reading the latest submission", () => { return createRoot(() => { - const testAction = action(async (data: string) => data, "filter-submission-test"); + const testAction = formAction(async (data: string) => data, "filter-submission-test"); mockRouterContext.submissions[1](submissions => [ ...submissions, @@ -549,8 +550,8 @@ describe("useSubmissions", () => { clear: vi.fn(), retry: vi.fn() }); - const a = action(async () => "a", "identity-a"); - const b = action(async () => "b", "identity-b"); + const a = formAction(async () => "a", "identity-a"); + const b = formAction(async () => "b", "identity-b"); let runs = 0; const dispose = createRoot(dispose => { const submissions = useSubmissions(a); @@ -584,7 +585,7 @@ describe("useAction", () => { test("should return bound action function", () => { return createRoot(() => { - const testAction = action(async (data: string) => `result: ${data}`, "bound-test"); + const testAction = formAction(async (data: string) => `result: ${data}`, "bound-test"); const boundAction = useAction(testAction); expect(typeof boundAction).toBe("function"); @@ -592,7 +593,7 @@ describe("useAction", () => { }); test("should execute action through useAction", async () => { - const testAction = action(async (data: string) => { + const testAction = formAction(async (data: string) => { await new Promise(resolve => setTimeout(resolve, 1)); return `result: ${data}`; }, "context-test"); @@ -614,13 +615,13 @@ describe("form aria-busy", () => { }); // invoke the action the way handleFormAction does: with the form as context - const submitViaForm = (act: Action, form: HTMLFormElement, data: any = {}) => + const submitViaForm = (act: FormAction, form: HTMLFormElement, data: any = {}) => (act as any).call({ r: mockRouterContext, f: form }, data); test("marks the form while the action is in flight and clears after settle", async () => { let release!: () => void; const gate = new Promise(resolve => (release = resolve)); - const busyAction = action(async () => { + const busyAction = formAction(async () => { await gate; return "done"; }, "busy-test"); @@ -637,7 +638,7 @@ describe("form aria-busy", () => { test("clears even when the action errors", async () => { let reject!: (e: Error) => void; const gate = new Promise((_, r) => (reject = r)); - const failing = action(async () => { + const failing = formAction(async () => { await gate; }, "busy-error-test"); const form = document.createElement("form"); @@ -654,7 +655,7 @@ describe("form aria-busy", () => { test("stays busy across overlapping submissions of the same form", async () => { const releases: (() => void)[] = []; - const overlapping = action( + const overlapping = formAction( () => new Promise(resolve => releases.push(resolve)), "busy-overlap-test" ); @@ -674,7 +675,7 @@ describe("form aria-busy", () => { }); test("releases when the invocation is refused before a transition begins", async () => { - const refused = action(async () => "ok", "busy-refused-test"); + const refused = formAction(async () => "ok", "busy-refused-test"); const form = document.createElement("form"); form.setAttribute("action", String(refused)); // dev refuses invoking an action inside an owned scope, synchronously @@ -689,7 +690,7 @@ describe("form aria-busy", () => { }); test("does not touch forms for programmatic (formless) calls", async () => { - const plain = action(async () => "ok", "busy-formless-test"); + const plain = formAction(async () => "ok", "busy-formless-test"); await useAction(plain)(); // nothing to assert on a form — the run simply must not throw without one expect(mockRouterContext.submissions[0]()).toHaveLength(1); @@ -956,7 +957,7 @@ describe("generic server actions", () => { vi.fn(async (_id: string, _form: unknown) => ({ ok: true })), { url: "/_server/toggle%230" } ); - const toggle = action(serverFn); + const toggle = formAction(serverFn); const hook = vi.fn(); toggle.onSubmit(hook); const ref = `/_server/toggle%230?args=${encodeURIComponent(JSON.stringify(["a"]))}`; @@ -977,7 +978,7 @@ describe("generic server actions", () => { test("a server-rendered `.with()` form on a client action runs through its base action", async () => { const clientFn = vi.fn(async (_id: string, _form: unknown) => ({ ok: true })); - const toggle = action(clientFn, "toggle"); + const toggle = formAction(clientFn, "toggle"); const hook = vi.fn(); toggle.onSubmit(hook); const ref = `${toggle.url}?args=${encodeURIComponent(JSON.stringify(["a"]))}`; diff --git a/test/data/deprecated.spec.ts b/test/data/deprecated.spec.ts new file mode 100644 index 00000000..38a0600f --- /dev/null +++ b/test/data/deprecated.spec.ts @@ -0,0 +1,24 @@ +import { DEV } from "solid-js"; +import { vi } from "vitest"; +import { action } from "../../src/data/deprecated.js"; +import { formAction } from "../../src/data/action.js"; + +describe("deprecated action alias", () => { + afterEach(() => vi.restoreAllMocks()); + + test("is formAction outside dev", () => { + if (DEV) return; + expect(action).toBe(formAction); + }); + + test("warns once in dev, naming formAction, and still creates a form action", () => { + if (!DEV) return; + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const a = action(async () => {}, "deprecated-alias-a"); + action(async () => {}, "deprecated-alias-b"); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0][0]).toContain("`action` is deprecated"); + expect(warn.mock.calls[0][0]).toContain("`formAction`"); + expect(a.url).toBe(formAction(async () => {}, "deprecated-alias-a").url); + }); +}); diff --git a/test/data/flight-consumer.spec.ts b/test/data/flight-consumer.spec.ts index 315eaa5f..833d73af 100644 --- a/test/data/flight-consumer.spec.ts +++ b/test/data/flight-consumer.spec.ts @@ -39,9 +39,9 @@ vi.mock("@solidjs/web/server-functions", () => ({ } })); -import { action, setupFlightDataConsumer } from "../../src/data/action.js"; +import { formAction } from "../../src/data/action.js"; +import { setupFlightDataConsumer } from "../../src/data/query.js"; import { query } from "../../src/data/query.js"; -import { registerFlightRouter } from "../../src/routing.js"; describe("setupFlightDataConsumer", () => { let router: RouterContext; @@ -79,7 +79,7 @@ describe("setupFlightDataConsumer", () => { { "notes[]": ["destination data"] }, { response: new Response(null, { headers: carrier("/notes") }) } ); - expect(navigate).toHaveBeenCalledWith("/notes", { replace: true }); + expect(navigate).toHaveBeenCalledWith("/notes", { replace: true, resolve: true }); expect(query.get("notes[]")).toEqual(["destination data"]); }); @@ -111,7 +111,7 @@ describe("setupFlightDataConsumer", () => { ); return "saved"; }; - await action(save, "save-all").call({ r: router }); + await formAction(save, "save-all").call({ r: router }); await other(); expect(query.get("notes[]")).toEqual(["fresh"]); @@ -130,7 +130,7 @@ describe("setupFlightDataConsumer", () => { await consumer!({}, { response: new Response(null, { headers: { "X-Revalidate": "" } }) }); return "saved"; }; - await action(save, "save-nothing").call({ r: router }); + await formAction(save, "save-nothing").call({ r: router }); await other(); expect(fetchOther).toHaveBeenCalledTimes(1); @@ -152,7 +152,7 @@ describe("setupFlightDataConsumer", () => { return "saved"; }; - await action(save, "save-notes").call({ r: router }); + await formAction(save, "save-notes").call({ r: router }); expect(await notes()).toEqual(["fresh"]); expect(fetchNotes).toHaveBeenCalledTimes(1); @@ -175,9 +175,9 @@ describe("setupFlightDataConsumer", () => { ); return "saved"; }; - await action(save, "keyless-save").call({ r: router }); + await formAction(save, "keyless-save").call({ r: router }); - expect(navigate).toHaveBeenCalledWith("/dash/b", { replace: true }); + expect(navigate).toHaveBeenCalledWith("/dash/b", { replace: true, resolve: true }); expect(await layout()).toBe("fresh-layout"); expect(fetchLayout).toHaveBeenCalledTimes(1); }); @@ -195,7 +195,7 @@ describe("setupFlightDataConsumer", () => { await consumer!({ "layout[]": "fresh-layout" }, { response: new Response(null) }); return "saved"; }; - await action(save, "keyless-save").call({ r: router }); + await formAction(save, "keyless-save").call({ r: router }); await other(); expect(fetchOther).toHaveBeenCalledTimes(2); @@ -206,7 +206,7 @@ describe("setupFlightDataConsumer", () => { const notes = query(fetchNotes, "notes"); await notes(); - await action(async () => "saved", "save-notes").call({ r: router }); + await formAction(async () => "saved", "save-notes").call({ r: router }); await notes(); expect(fetchNotes).toHaveBeenCalledTimes(2); @@ -222,89 +222,9 @@ describe("setupFlightDataConsumer", () => { // invokes the consumer (no referrer, empty collection pass, or no // server-side collector). The default revalidation must still run. setupFlightDataConsumer(router); - await action(async () => "saved", "save-notes").call({ r: router }); + await formAction(async () => "saved", "save-notes").call({ r: router }); await notes(); expect(fetchNotes).toHaveBeenCalledTimes(2); }); }); - -// The Router no longer imports setupFlightDataConsumer; it registers itself -// into a rendezvous in routing.ts, and the action side provides the -// consumer factory on first action creation. These tests exercise the -// rendezvous ordering on fresh module instances (the slots are -// module-global and first-provide-wins). -describe("flight consumer rendezvous", () => { - const loadRouting = async () => { - vi.resetModules(); - return await import("../../src/routing.js"); - }; - - test("provide before register subscribes at registration and unsubscribes on cleanup", async () => { - const { registerFlightRouter, provideFlightConsumer } = await loadRouting(); - const unsubscribe = vi.fn(); - const factory = vi.fn(() => unsubscribe); - provideFlightConsumer(factory); - - const router = createMockRouter(); - const cleanup = registerFlightRouter(router); - expect(factory).toHaveBeenCalledWith(router); - - cleanup(); - expect(unsubscribe).toHaveBeenCalled(); - }); - - test("register before provide attaches when the consumer arrives", async () => { - const { registerFlightRouter, provideFlightConsumer } = await loadRouting(); - const unsubscribe = vi.fn(); - const factory = vi.fn(() => unsubscribe); - - const router = createMockRouter(); - const cleanup = registerFlightRouter(router); - expect(factory).not.toHaveBeenCalled(); - - provideFlightConsumer(factory); - expect(factory).toHaveBeenCalledWith(router); - - cleanup(); - expect(unsubscribe).toHaveBeenCalled(); - }); - - test("a router unregistered before the consumer arrives is never subscribed", async () => { - const { registerFlightRouter, provideFlightConsumer } = await loadRouting(); - const factory = vi.fn(() => vi.fn()); - - const cleanup = registerFlightRouter(createMockRouter()); - cleanup(); - - provideFlightConsumer(factory); - expect(factory).not.toHaveBeenCalled(); - }); - - test("the first provided consumer wins", async () => { - const { registerFlightRouter, provideFlightConsumer } = await loadRouting(); - const first = vi.fn(() => vi.fn()); - const second = vi.fn(() => vi.fn()); - - provideFlightConsumer(first); - provideFlightConsumer(second); - registerFlightRouter(createMockRouter()); - - expect(first).toHaveBeenCalled(); - expect(second).not.toHaveBeenCalled(); - }); - - // End-to-end over the real modules (static imports above): creating an - // action provides setupFlightDataConsumer, which subscribes for the - // already-registered router through the mocked transport. - test("creating an action installs the consumer for a registered router", () => { - consumer = undefined; - const cleanup = registerFlightRouter(createMockRouter()); - - action(async () => "result", "rendezvous-install-test"); - expect(typeof consumer).toBe("function"); - - cleanup(); - expect(consumer).toBeUndefined(); - }); -}); diff --git a/test/data/flight-redirect.spec.ts b/test/data/flight-redirect.spec.ts index dcc2e0d1..f3361baa 100644 --- a/test/data/flight-redirect.spec.ts +++ b/test/data/flight-redirect.spec.ts @@ -39,25 +39,14 @@ const carrier = (target: string, status = 302) => ({ "X-Server-Function-Redirect": `${status} ${new URL(target, window.location.href).href}` }); -// Spy on the sweep: these tests pin the ordering the action layer applies to -// a flight response — invalidate, seed, navigate, then sweep synchronously — -// not the query cache mechanics (covered by query.spec.ts and -// flight-consumer.spec.ts). -const hoisted = vi.hoisted(() => ({ - sweepSpy: vi.fn(), - actualQuery: undefined as unknown as typeof import("../../src/data/query.js") -})); -const { sweepSpy } = hoisted; -vi.mock("../../src/data/query.js", async importOriginal => { - hoisted.actualQuery = await importOriginal(); - return { - ...hoisted.actualQuery, - revalidate: (...args: unknown[]) => hoisted.sweepSpy(...args) - }; -}); - -import { setupFlightDataConsumer } from "../../src/data/action.js"; -import { query } from "../../src/data/query.js"; +// Spy on the sweep through the revalidate hook seam: these tests pin the +// ordering applied to a flight response — invalidate, seed, navigate, then +// sweep synchronously — not the query cache mechanics (covered by +// query.spec.ts and flight-consumer.spec.ts). +const sweepSpy = vi.fn(); +import { setupFlightDataConsumer } from "../../src/data/query.js"; +import { query, registerRevalidateHook } from "../../src/data/query.js"; +registerRevalidateHook(sweepSpy); // A redirecting flight response commits atomically: the sweep runs in the // same synchronous pass as the navigation so both join one transition. diff --git a/test/flight-subscribe-at-mount.spec.tsx b/test/flight-subscribe-at-mount.spec.tsx new file mode 100644 index 00000000..de3fdf58 --- /dev/null +++ b/test/flight-subscribe-at-mount.spec.tsx @@ -0,0 +1,50 @@ +// Single flight belongs to the router, not the action layer: a mounted Router +// subscribes to the server function transport whether or not any action +// exists, so a server function called straight from an event handler still +// gets its revalidation and redirect applied. `singleFlight: false` never +// subscribes, and unmounting unsubscribes. +import { render } from "@solidjs/web"; +import { vi } from "vitest"; + +const subscriptions = vi.hoisted(() => ({ active: 0, total: 0 })); +vi.mock("@solidjs/web/server-functions", async importOriginal => ({ + ...(await importOriginal()), + subscribeFlightData: () => { + subscriptions.active++; + subscriptions.total++; + return () => subscriptions.active--; + } +})); + +import { createRouter, memoryHistory } from "../src/index.js"; + +function mount(singleFlight?: boolean) { + const Router = createRouter({ + history: memoryHistory(), + singleFlight, + routes: [{ path: "/", component: () =>
Home
}] + }); + const div = document.createElement("div"); + const dispose = render(() => , div); + return dispose; +} + +describe("flight consumer subscription", () => { + beforeEach(() => { + subscriptions.active = 0; + subscriptions.total = 0; + }); + + test("a router with no actions subscribes at mount and unsubscribes on unmount", () => { + const dispose = mount(); + expect(subscriptions.active).toBe(1); + dispose(); + expect(subscriptions.active).toBe(0); + }); + + test("singleFlight: false never subscribes", () => { + const dispose = mount(false); + expect(subscriptions.total).toBe(0); + dispose(); + }); +}); diff --git a/test/observe-navigation.spec.tsx b/test/observe-navigation.spec.tsx index 742de646..07b87fce 100644 --- a/test/observe-navigation.spec.tsx +++ b/test/observe-navigation.spec.tsx @@ -9,7 +9,7 @@ import { render } from "@solidjs/web"; import { attribution, feedback } from "solid-js/attribution"; import { vi } from "vitest"; import { - action, + formAction, createRouter, defineRoutes, memoryHistory, @@ -519,7 +519,7 @@ describe("observe tier: native link clicks and form submits are interactions (#6 test("a native form submit to an action is the action's interaction", async () => { const [saves, setSaves] = createSignal(0, { ownedWrite: true }); - const save = action(async (_form: FormData) => { + const save = formAction(async (_form: FormData) => { setSaves(n => n + 1); return "saved"; }, "observe-643-save"); diff --git a/test/optimistic-action-atomic-commit.spec.tsx b/test/optimistic-action-atomic-commit.spec.tsx index f3194ab6..bb508078 100644 --- a/test/optimistic-action-atomic-commit.spec.tsx +++ b/test/optimistic-action-atomic-commit.spec.tsx @@ -2,7 +2,7 @@ import { action, createMemo, createOptimisticStore, For, Loading } from "solid-js"; import { render } from "@solidjs/web"; import { - action as routerAction, + formAction as routerAction, createRouter, memoryHistory, query, diff --git a/test/optimistic-action-settle.spec.tsx b/test/optimistic-action-settle.spec.tsx index 6e98ef0d..2a6b9478 100644 --- a/test/optimistic-action-settle.spec.tsx +++ b/test/optimistic-action-settle.spec.tsx @@ -2,7 +2,7 @@ import { action, createMemo, createOptimisticStore, Loading } from "solid-js"; import { redirect, render } from "@solidjs/web"; import { - action as routerAction, + formAction as routerAction, createRouter, memoryHistory, query, diff --git a/test/server/flash-decode-per-request.spec.tsx b/test/server/flash-decode-per-request.spec.tsx index 4d13e8f8..aa7fd387 100644 --- a/test/server/flash-decode-per-request.spec.tsx +++ b/test/server/flash-decode-per-request.spec.tsx @@ -8,7 +8,7 @@ import { Errored, Loading } from "solid-js"; import { createRequestEvent, renderToStream, type JSX } from "@solidjs/web"; import { provideRequestEvent } from "@solidjs/web/storage"; import { decodeFlashCookie, encodeFlashCookie } from "@solidjs/web/server-functions/server"; -import { action, useSubmissions } from "../../src/data/action.js"; +import { formAction, useSubmissions } from "../../src/data/action.js"; import { provideFlashDecoder } from "../../src/routing.js"; import { createRouter } from "../../src/index.js"; @@ -21,7 +21,7 @@ provideFlashDecoder(cookieHeader => { return decodeFlashCookie(cookieHeader); }); -const save = action( +const save = formAction( Object.assign(async () => ({ error: "Failed to save" }), { url: "/_server/save" }) );