@void/react 1.2.1 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.mts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { AnchorHTMLAttributes, ButtonHTMLAttributes, Context, FormHTMLAttributes, JSX, ReactElement, ReactNode } from "react";
2
- import { ActionClient, FormMethod, FormModel, NavigationState, VoidActionError, VoidRouter } from "void/pages-client";
2
+ import { ActionClient, FormMethod, FormModel, FormResetOptions, NavigationState, VoidActionError, VoidRouter } from "void/pages-client";
3
3
  import { CloudContextVariables, InferProps } from "void";
4
4
  import { ActionFormOptions, ActionUrl, ResolveActionBody } from "void/routes";
5
5
  //#region src/runtime/form.d.ts
@@ -41,7 +41,13 @@ export declare function useParams<T extends Record<string, string> = Record<stri
41
41
  export declare function useShared<T = CloudContextVariables["shared"]>(): T;
42
42
  //#endregion
43
43
  //#region src/runtime/use-form.d.ts
44
- export declare function useForm<U extends ActionUrl & string>(url: U, defaults: ResolveActionBody<U>, options?: ActionFormOptions<U>): {
44
+ type FormOptions = {
45
+ params?: Record<string, string>;
46
+ resetOnSuccess?: boolean;
47
+ onSuccess?: () => void | Promise<void>;
48
+ onError?: (error: Record<string, string> | VoidActionError) => void | Promise<void>;
49
+ };
50
+ export declare function useForm<U extends ActionUrl & string>(url: U, defaults: ResolveActionBody<U>, options?: ActionFormOptions<U> & Pick<FormOptions, "onSuccess" | "onError">): {
45
51
  data: ResolveActionBody<U>;
46
52
  setData: <K extends keyof ResolveActionBody<U> & string>(field: K, value: ResolveActionBody<U>[K]) => void;
47
53
  errors: Partial<Record<keyof ResolveActionBody<U> & string, string>>;
@@ -54,7 +60,9 @@ export declare function useForm<U extends ActionUrl & string>(url: U, defaults:
54
60
  put(formData: FormData): void;
55
61
  patch(formData: FormData): void;
56
62
  delete(formData: FormData): void;
63
+ reset(options: FormResetOptions, ...fields: ReadonlyArray<keyof ResolveActionBody<U> & string>): void;
57
64
  reset(...fields: ReadonlyArray<keyof ResolveActionBody<U> & string>): void;
65
+ setError(field: keyof ResolveActionBody<U> & string, message: string): void;
58
66
  clearErrors(...fields: ReadonlyArray<keyof ResolveActionBody<U> & string>): void;
59
67
  clearError(): void;
60
68
  };
package/dist/index.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { a as RouterContext, i as ParamsContext, n as FrontmatterContext, o as SharedContext, r as NavigationContext, t as ErrorsContext } from "./context-D2ctU4yT.mjs";
2
2
  import { n as setActionRouter, t as action } from "./action-4EvQppia.mjs";
3
3
  import { useActionState, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
4
- import { VoidActionError, categorizeActionError, formErrorMessage, formRetryMessage, formSubmissionBlocked, getValidationErrors, interpolateRouteParams, isAbortError, isEqualFormValue, isModifiedEvent, mergeDataIntoHref, scheduleFormRetry, ssrProxy, submitAction } from "void/pages-client";
4
+ import { cloneFormValue, formErrorMessage, formRetryMessage, formSubmissionBlocked, getValidationErrors, interpolateRouteParams, isAbortError, isEqualFormValue, isModifiedEvent, mergeDataIntoHref, parseFormResetArgs, scheduleFormRetry, ssrProxy, submitAction, submitIslandAction } from "void/pages-client";
5
5
  import { jsx, jsxs } from "react/jsx-runtime";
6
6
  //#region src/runtime/form.tsx
7
7
  function Form({ form, method = "post", renderError, children, ...props }) {
@@ -231,13 +231,14 @@ function useShared() {
231
231
  //#region src/runtime/use-form.ts
232
232
  function useForm(url, defaults, options) {
233
233
  const router = useContext(RouterContext);
234
- const [data, setDataState] = useState(() => ({ ...defaults }));
235
- const [defaultsState, setDefaultsState] = useState(() => ({ ...defaults }));
234
+ const [data, setDataState] = useState(() => cloneFormValue(defaults));
235
+ const [defaultsState, setDefaultsState] = useState(() => cloneFormValue(defaults));
236
236
  const [errors, setErrors] = useState({});
237
- const [error, setError] = useState(null);
237
+ const [error, setActionError] = useState(null);
238
238
  const [wasSuccessful, setWasSuccessful] = useState(false);
239
239
  const [recentlySuccessful, setRecentlySuccessful] = useState(false);
240
240
  const defaultsRef = useRef(defaultsState);
241
+ const initialRef = useRef(defaultsState);
241
242
  const dataRef = useRef(data);
242
243
  const successTimeoutRef = useRef(null);
243
244
  const hasChanges = useMemo(() => {
@@ -249,12 +250,20 @@ function useForm(url, defaults, options) {
249
250
  [field]: value
250
251
  };
251
252
  setDataState(dataRef.current);
253
+ setErrors((prev) => {
254
+ if (!(field in prev)) return prev;
255
+ const next = { ...prev };
256
+ delete next[field];
257
+ return next;
258
+ });
252
259
  }, []);
253
- const reset = useCallback((...fields) => {
254
- if (fields.length === 0) dataRef.current = { ...defaultsRef.current };
260
+ const reset = useCallback((...args) => {
261
+ const { initial, fields } = parseFormResetArgs(args);
262
+ const source = initial ? initialRef.current : defaultsRef.current;
263
+ if (fields.length === 0) dataRef.current = cloneFormValue(source);
255
264
  else {
256
265
  const next = { ...dataRef.current };
257
- for (const field of fields) next[field] = defaultsRef.current[field];
266
+ for (const field of fields) next[field] = cloneFormValue(source[field]);
258
267
  dataRef.current = next;
259
268
  }
260
269
  setDataState(dataRef.current);
@@ -267,8 +276,14 @@ function useForm(url, defaults, options) {
267
276
  return next;
268
277
  });
269
278
  }, []);
279
+ const setError = useCallback((field, message) => {
280
+ setErrors((prev) => ({
281
+ ...prev,
282
+ [field]: message
283
+ }));
284
+ }, []);
270
285
  const clearError = useCallback(() => {
271
- setError(null);
286
+ setActionError(null);
272
287
  }, []);
273
288
  const [, dispatchAction, pending] = useActionState(async (previousSuccessVersion, payload) => {
274
289
  if (!router) throw new Error("useForm(): requires the Void Router.");
@@ -280,7 +295,7 @@ function useForm(url, defaults, options) {
280
295
  successTimeoutRef.current = null;
281
296
  }
282
297
  setErrors({});
283
- setError(null);
298
+ setActionError(null);
284
299
  let result;
285
300
  try {
286
301
  result = await submitAction(router, interpolateRouteParams(url, options?.params), {
@@ -294,11 +309,20 @@ function useForm(url, defaults, options) {
294
309
  }
295
310
  if (!result.ok) {
296
311
  const validationErrors = getValidationErrors(result.error.body);
297
- if (validationErrors) setErrors(validationErrors);
298
- else setError(result.error);
312
+ if (validationErrors) {
313
+ setErrors(validationErrors);
314
+ await options?.onError?.(validationErrors);
315
+ } else {
316
+ setActionError(result.error);
317
+ await options?.onError?.(result.error);
318
+ }
299
319
  return previousSuccessVersion;
300
320
  }
301
- const nextDefaults = { ...dataRef.current };
321
+ if (options?.resetOnSuccess) {
322
+ dataRef.current = cloneFormValue(initialRef.current);
323
+ setDataState(dataRef.current);
324
+ }
325
+ const nextDefaults = cloneFormValue(dataRef.current);
302
326
  defaultsRef.current = nextDefaults;
303
327
  setDefaultsState(nextDefaults);
304
328
  setWasSuccessful(true);
@@ -307,6 +331,7 @@ function useForm(url, defaults, options) {
307
331
  setRecentlySuccessful(false);
308
332
  successTimeoutRef.current = null;
309
333
  }, 2e3);
334
+ await options?.onSuccess?.();
310
335
  return previousSuccessVersion + 1;
311
336
  }, 0);
312
337
  useEffect(() => {
@@ -334,6 +359,7 @@ function useForm(url, defaults, options) {
334
359
  patch: useMemo(() => dispatchMethodAction("PATCH"), [dispatchMethodAction]),
335
360
  delete: useMemo(() => dispatchMethodAction("DELETE"), [dispatchMethodAction]),
336
361
  reset,
362
+ setError,
337
363
  clearErrors,
338
364
  clearError
339
365
  };
@@ -345,15 +371,6 @@ function useNavigation() {
345
371
  }
346
372
  //#endregion
347
373
  //#region src/runtime/use-island-form.ts
348
- async function readActionErrorBody(response) {
349
- const contentType = response.headers.get("content-type") || "";
350
- try {
351
- if (contentType.includes("application/json")) return await response.json();
352
- return await response.text() || null;
353
- } catch {
354
- return null;
355
- }
356
- }
357
374
  /**
358
375
  * Form hook for island pages. Uses fetch + page reload instead of the
359
376
  * Void Router (which is not available in island mode).
@@ -404,16 +421,12 @@ function useIslandForm(defaults) {
404
421
  setErrors({});
405
422
  setError(null);
406
423
  try {
407
- const response = await fetch(url, {
408
- method,
409
- headers: { "Content-Type": "application/json" },
410
- body: JSON.stringify(data)
411
- });
412
- if (response.redirected) {
413
- window.location.href = response.url;
424
+ const result = await submitIslandAction(url, method, data);
425
+ if (result.kind === "redirect") {
426
+ window.location.href = result.url;
414
427
  return;
415
428
  }
416
- if (response.ok) {
429
+ if (result.kind === "success") {
417
430
  setWasSuccessful(true);
418
431
  setRecentlySuccessful(true);
419
432
  const nextDefaults = { ...data };
@@ -421,22 +434,8 @@ function useIslandForm(defaults) {
421
434
  setDefaultsState(nextDefaults);
422
435
  successTimeoutRef.current = setTimeout(() => setRecentlySuccessful(false), 2e3);
423
436
  window.location.reload();
424
- } else {
425
- const body = await readActionErrorBody(response);
426
- const actionError = new VoidActionError({
427
- body,
428
- status: response.status,
429
- statusText: response.statusText,
430
- url
431
- });
432
- const validationErrors = response.status === 422 ? getValidationErrors(body) : null;
433
- if (validationErrors) {
434
- setErrors(validationErrors);
435
- return;
436
- }
437
- if (categorizeActionError(response.status) === "boundary") throw actionError;
438
- setError(actionError);
439
- }
437
+ } else if (result.kind === "validation") setErrors(result.errors);
438
+ else setError(result.error);
440
439
  } finally {
441
440
  setPending(false);
442
441
  }
package/dist/plugin.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import react from "@vitejs/plugin-react";
2
- import { generateComponentManifest, resolveEffectiveLayoutChain, resolvePagesDir, resolveStaticPageDataBuildOptions, scanPages } from "void/pages";
3
- import { existsSync, readFileSync, readdirSync } from "node:fs";
2
+ import { createPagesPlugin, generateComponentManifest, resolveEffectiveLayoutChain, resolvePagesDir, scanPages } from "void/pages";
3
+ import { existsSync, readFileSync } from "node:fs";
4
4
  import nodePath from "node:path";
5
5
  import { normalizePath } from "vite";
6
6
  import { ISLANDS_CLIENT_ENTRY_ID, ISLANDS_RENDERER_ID, computePagesNeedingClientJS, editIslandSource, findIslandImports, findStaticCssImports, hasIslandPages, islandStylesPlugin, recordIslandStyles, recordedIslandStyles, resolveSpecifier, rewriteCssImportForVirtualModule, watchPagesForRescan } from "void/pages-islands-plugin";
@@ -44,7 +44,6 @@ posix.win32 = win32.win32 = win32;
44
44
  const { basename, delimiter, dirname, extname, format, isAbsolute, join, matchesGlob, normalize, parse, relative, resolve, sep, toNamespacedPath } = isWindows ? win32 : posix;
45
45
  //#endregion
46
46
  //#region src/plugin-pages.ts
47
- const RENDERER_ID = "virtual:void-pages-renderer";
48
47
  const CLIENT_ENTRY_ID = "virtual:void-pages-client";
49
48
  const WORKER_ENVIRONMENT_NAME = "void_worker";
50
49
  const REACT_DEDUPE = ["react", "react-dom"];
@@ -68,112 +67,38 @@ const REACT_REFRESH_PREAMBLE = `
68
67
  window.$RefreshSig$ = () => (type) => type
69
68
  window.__vite_plugin_react_preamble_installed__ = true
70
69
  <\/script>`;
71
- /** Check if the pages directory has any non-island page files (.tsx/.jsx without .island.). */
72
- function hasNonIslandPages(dir) {
73
- try {
74
- for (const entry of readdirSync(dir, {
75
- withFileTypes: true,
76
- recursive: true
77
- })) {
78
- if (!entry.isFile()) continue;
79
- const name = entry.name;
80
- if (name.startsWith("_") || name.startsWith("layout.")) continue;
81
- if (/\.(tsx|jsx)$/.test(name) && !name.includes(".island.")) return true;
82
- }
83
- } catch {}
84
- return false;
85
- }
86
70
  function pagesPlugin(pagesDir, options) {
87
- let pageScan = null;
88
- let isDev = false;
89
- let viteConfig;
90
71
  const root = options?.root ?? dirname(pagesDir);
91
- const normalizedPagesDir = normalizePath(pagesDir);
92
72
  const pagesOptimizeEntries = `${relative(root, pagesDir)}/**/*.{js,jsx,ts,tsx}`;
93
- let pendingRescan = null;
94
- const rescanPages = async () => {
95
- pageScan = existsSync(pagesDir) ? await scanPages(root, { pagesDir }) : null;
96
- };
97
- const runRescan = () => {
98
- pendingRescan ??= rescanPages().finally(() => {
99
- pendingRescan = null;
100
- });
101
- return pendingRescan;
102
- };
103
- const invalidatePageModules = (server) => {
104
- for (const env of Object.values(server.environments)) for (const id of ["\0" + RENDERER_ID, "\0" + CLIENT_ENTRY_ID]) {
105
- const mod = env.moduleGraph.getModuleById(id);
106
- if (mod) env.moduleGraph.invalidateModule(mod);
107
- }
108
- };
109
- return {
73
+ return createPagesPlugin(pagesDir, {
110
74
  name: "void-react:pages",
111
- async buildStart() {
112
- await runRescan();
113
- },
114
- resolveId: {
115
- filter: { id: /^virtual:void-pages-(renderer|client)$/ },
116
- handler(id) {
117
- return "\0" + id;
118
- }
75
+ root,
76
+ generateRenderer(scan, isDev) {
77
+ return generateRendererModule(scan, pagesDir, isDev);
119
78
  },
120
- load: {
121
- filter: { id: /^\0virtual:void-pages-(renderer|client)$/ },
122
- async handler(id) {
123
- if (isDev && pendingRescan) await pendingRescan;
124
- if (!pageScan && existsSync(pagesDir)) await runRescan();
125
- if (id === "\0" + RENDERER_ID) {
126
- if (!pageScan) return "export function renderPage() { throw new Error('No pages found'); }";
127
- return generateRendererModule(pageScan, pagesDir, isDev);
128
- }
129
- if (id === "\0" + CLIENT_ENTRY_ID) {
130
- if (!pageScan) return "";
131
- return generateClientEntry(pageScan, pagesDir, options?.viewTransitions, options?.prefetch, resolveStaticPageDataBuildOptions(root, { publicDir: viteConfig?.publicDir }));
132
- }
133
- }
79
+ generateClient(scan, staticPageData) {
80
+ return generateClientEntry(scan, pagesDir, options?.viewTransitions, options?.prefetch, staticPageData);
134
81
  },
135
- configureServer(server) {
136
- const handlePagesChange = async (path, event) => {
137
- const normalizedPath = normalizePath(path);
138
- if (normalizedPath !== normalizedPagesDir && !normalizedPath.startsWith(normalizedPagesDir + "/")) return;
139
- if (event === "change" && !normalizedPath.endsWith(".md")) return;
140
- try {
141
- await runRescan();
142
- } catch (error) {
143
- server.config.logger.error(error instanceof Error ? error.message : String(error), {
144
- error: error instanceof Error ? error : void 0,
145
- timestamp: true
146
- });
147
- return;
148
- }
149
- invalidatePageModules(server);
82
+ async config({ isDev, scan }) {
83
+ if (isDev) return {
84
+ resolve: { dedupe: REACT_DEDUPE },
85
+ optimizeDeps: {
86
+ exclude: ["@void/react"],
87
+ entries: clientOptimizeEntries(await scan(), pagesDir, root),
88
+ include: [
89
+ ...CLIENT_OPTIMIZE_DEPS,
90
+ "void/pages-client",
91
+ "void/pages-prefetch"
92
+ ]
93
+ },
94
+ ssr: { optimizeDeps: { include: [...CLIENT_OPTIMIZE_DEPS, "react-dom/server"] } },
95
+ environments: { [WORKER_ENVIRONMENT_NAME]: { optimizeDeps: {
96
+ exclude: ["@void/react"],
97
+ entries: [pagesOptimizeEntries],
98
+ include: WORKER_OPTIMIZE_DEPS
99
+ } } }
150
100
  };
151
- server.watcher.on("add", (path) => handlePagesChange(path, "add"));
152
- server.watcher.on("change", (path) => handlePagesChange(path, "change"));
153
- server.watcher.on("unlink", (path) => handlePagesChange(path, "unlink"));
154
- },
155
- configResolved(config) {
156
- viteConfig = config;
157
- },
158
- config(_, env) {
159
- if (!existsSync(pagesDir)) return;
160
- if (env.command === "serve") {
161
- isDev = true;
162
- return {
163
- resolve: { dedupe: REACT_DEDUPE },
164
- optimizeDeps: {
165
- exclude: ["@void/react"],
166
- include: CLIENT_OPTIMIZE_DEPS
167
- },
168
- ssr: { optimizeDeps: { include: [...CLIENT_OPTIMIZE_DEPS, "react-dom/server"] } },
169
- environments: { [WORKER_ENVIRONMENT_NAME]: { optimizeDeps: {
170
- exclude: ["@void/react"],
171
- entries: [pagesOptimizeEntries],
172
- include: WORKER_OPTIMIZE_DEPS
173
- } } }
174
- };
175
- }
176
- if (!hasNonIslandPages(pagesDir)) return;
101
+ if (!(await scan())?.pages.some((page) => !page.island)) return;
177
102
  return {
178
103
  resolve: { dedupe: REACT_DEDUPE },
179
104
  build: {
@@ -182,7 +107,35 @@ function pagesPlugin(pagesDir, options) {
182
107
  }
183
108
  };
184
109
  }
185
- };
110
+ });
111
+ }
112
+ function clientOptimizeEntries(scan, pagesDir, root) {
113
+ if (!scan) return [];
114
+ const browserFiles = /* @__PURE__ */ new Set();
115
+ const islandFiles = /* @__PURE__ */ new Set();
116
+ for (const page of scan.pages) {
117
+ const layouts = resolveEffectiveLayoutChain(page, scan.layouts, scan.namedLayouts);
118
+ if (page.island) {
119
+ islandFiles.add(resolve(pagesDir, page.componentPath));
120
+ for (const layout of layouts) if (layout.island) islandFiles.add(resolve(pagesDir, layout.filePath));
121
+ } else {
122
+ browserFiles.add(resolve(pagesDir, page.componentPath));
123
+ for (const layout of layouts) browserFiles.add(resolve(pagesDir, layout.filePath));
124
+ }
125
+ }
126
+ for (const file of islandFiles) {
127
+ if (!/\.island\.(tsx|jsx)$/.test(file)) continue;
128
+ for (const { specifier } of findIslandImports(readFileSync(file, "utf8"), file)) {
129
+ const component = resolveSpecifier(specifier, dirname(file), [
130
+ ".tsx",
131
+ ".jsx",
132
+ ".ts",
133
+ ".js"
134
+ ]);
135
+ if (component) browserFiles.add(component);
136
+ }
137
+ }
138
+ return [...browserFiles].map((file) => relative(root, file).replace(/[()[\]{}*?!|]/g, "\\$&"));
186
139
  }
187
140
  function generateRendererModule(scan, pagesDir, isDev) {
188
141
  const preambleScript = isDev ? REACT_REFRESH_PREAMBLE : "";
@@ -272,7 +225,10 @@ function islandsPlugin(pagesDir, options) {
272
225
  },
273
226
  async buildStart() {
274
227
  if (existsSync(pagesDir)) {
275
- pageScan = await scanPages(root, { pagesDir });
228
+ pageScan = await scanPages(root, {
229
+ pagesDir,
230
+ environment: isDev ? "dev" : "prod"
231
+ });
276
232
  const filesToScan = [
277
233
  ...pageScan.pages.map((page) => ({
278
234
  filePath: page.componentPath,
@@ -12,7 +12,8 @@ export type ReactPagesConfig = Readonly<{
12
12
  routeMeta: Record<string, {
13
13
  island?: boolean;
14
14
  }>;
15
- matchRoute(url: string): string | null;
15
+ /** Page id for a decoded pathname. */
16
+ matchRoute(path: string): string | null;
16
17
  staticPageData: boolean;
17
18
  staticPageDataFastPath: boolean;
18
19
  viewTransitions: boolean;
@@ -182,12 +182,11 @@ function RouteResource({ resource, onCommit }) {
182
182
  resource,
183
183
  result
184
184
  ]);
185
- if (!result) return null;
186
- const pageData = result.pageData;
187
- const PageComponent = result.component;
188
- const routeParams = pageData.params ?? EMPTY_PARAMS;
185
+ const pageData = result?.pageData;
186
+ const pageDataUrl = pageData?.url ?? "";
187
+ const routeParams = pageData?.params ?? EMPTY_PARAMS;
189
188
  const routeRouter = useMemo(() => {
190
- const pageUrl = encodedPageUrl(pageData.url);
189
+ const pageUrl = encodedPageUrl(pageDataUrl);
191
190
  return createRouterFacade(outerRouter, () => {
192
191
  const outerUrl = outerRouter.url;
193
192
  const outerPath = outerUrl.pathname + outerUrl.search;
@@ -198,11 +197,13 @@ function RouteResource({ resource, onCommit }) {
198
197
  }, () => routeParams);
199
198
  }, [
200
199
  outerRouter,
201
- pageData.url,
200
+ pageDataUrl,
202
201
  resource.fetchUrl,
203
202
  resource.hash,
204
203
  routeParams
205
204
  ]);
205
+ if (!result || !pageData) return null;
206
+ const PageComponent = result.component;
206
207
  let element = React.createElement(PageComponent, pageData.props);
207
208
  for (let i = result.layouts.length - 1; i >= 0; i--) {
208
209
  const Layout = result.layouts[i];
@@ -249,6 +250,7 @@ function App({ engine, router, viewTransitions, navigationFallback: NavigationFa
249
250
  prerenderedUrl = null;
250
251
  React.startTransition(() => setHydrated(true));
251
252
  }
253
+ return true;
252
254
  },
253
255
  dispose() {}
254
256
  }), [engine, initialPageData]);
@@ -263,7 +265,7 @@ function App({ engine, router, viewTransitions, navigationFallback: NavigationFa
263
265
  const finishNavigation = useCallback((resource, result) => {
264
266
  if (committedResources.has(resource.id)) return;
265
267
  committedResources.add(resource.id);
266
- resource.commit().then(() => {
268
+ resource.commit().then((committed) => {
267
269
  const resolveViewTransition = viewTransitionWaiters.get(resource.id);
268
270
  if (resolveViewTransition) {
269
271
  viewTransitionWaiters.delete(resource.id);
@@ -272,8 +274,11 @@ function App({ engine, router, viewTransitions, navigationFallback: NavigationFa
272
274
  const visitSettler = pendingVisitSettlers.get(resource.id);
273
275
  if (visitSettler) {
274
276
  pendingVisitSettlers.delete(resource.id);
275
- visitSettler.resolve(result.pageData);
277
+ if (committed) visitSettler.resolve(result.pageData);
278
+ else if (resource.method.toUpperCase() === "GET") visitSettler.resolve(void 0);
279
+ else visitSettler.reject(new DOMException("Navigation aborted", "AbortError"));
276
280
  }
281
+ if (!committed) return;
277
282
  if (activePendingNavigationId === resource.id) activePendingNavigationId = null;
278
283
  dispatch({
279
284
  type: "finish",
@@ -281,11 +286,22 @@ function App({ engine, router, viewTransitions, navigationFallback: NavigationFa
281
286
  location: currentUrl()
282
287
  });
283
288
  }).catch((error) => {
289
+ const resolveViewTransition = viewTransitionWaiters.get(resource.id);
290
+ if (resolveViewTransition) {
291
+ viewTransitionWaiters.delete(resource.id);
292
+ resolveViewTransition();
293
+ }
284
294
  const visitSettler = pendingVisitSettlers.get(resource.id);
285
295
  if (visitSettler) {
286
296
  pendingVisitSettlers.delete(resource.id);
287
297
  visitSettler.reject(error);
288
- }
298
+ } else reportError(error);
299
+ if (activePendingNavigationId === resource.id) activePendingNavigationId = null;
300
+ dispatch({
301
+ type: "fail",
302
+ resource,
303
+ location: currentUrl()
304
+ });
289
305
  });
290
306
  }, []);
291
307
  const schedule = useCallback((url, options = {}) => {
@@ -334,7 +350,7 @@ function App({ engine, router, viewTransitions, navigationFallback: NavigationFa
334
350
  pendingVisitSettlers.delete(prepared.id);
335
351
  if (activePendingNavigationId === prepared.id) activePendingNavigationId = null;
336
352
  dispatch({
337
- type: NavigationFallback ? "abandon" : "finish",
353
+ type: "abandon",
338
354
  resource: prepared,
339
355
  location: currentUrl()
340
356
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@void/react",
3
- "version": "1.2.1",
3
+ "version": "1.4.0",
4
4
  "description": "React pages, server rendering, forms, and navigation for Void.",
5
5
  "homepage": "https://void.cloud",
6
6
  "bugs": {
@@ -44,18 +44,19 @@
44
44
  "@types/node": "^26.6.5",
45
45
  "@types/react": "^19.3.0",
46
46
  "@types/react-dom": "^19.3.0",
47
+ "happy-dom": "^21.0.4",
47
48
  "pathslash": "^0.1.0",
48
49
  "react": "^19.3.1",
49
50
  "react-dom": "^19.3.1",
50
51
  "vite": "npm:@voidzero-dev/vite-plus-core@1.3.0",
51
52
  "vite-plus": "1.3.0",
52
- "void": "1.2.1"
53
+ "void": "1.4.0"
53
54
  },
54
55
  "peerDependencies": {
55
56
  "react": "^19.0.0",
56
57
  "react-dom": "^19.0.0",
57
58
  "vite": "^8.0.0",
58
- "void": "1.2.1"
59
+ "void": "1.4.0"
59
60
  },
60
61
  "scripts": {
61
62
  "build": "vp pack",