@void/react 1.3.0 → 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
@@ -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>>;
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, cloneFormValue, formErrorMessage, formRetryMessage, formSubmissionBlocked, getValidationErrors, interpolateRouteParams, isAbortError, isEqualFormValue, isModifiedEvent, mergeDataIntoHref, parseFormResetArgs, 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 }) {
@@ -309,8 +309,13 @@ function useForm(url, defaults, options) {
309
309
  }
310
310
  if (!result.ok) {
311
311
  const validationErrors = getValidationErrors(result.error.body);
312
- if (validationErrors) setErrors(validationErrors);
313
- else setActionError(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
+ }
314
319
  return previousSuccessVersion;
315
320
  }
316
321
  if (options?.resetOnSuccess) {
@@ -326,6 +331,7 @@ function useForm(url, defaults, options) {
326
331
  setRecentlySuccessful(false);
327
332
  successTimeoutRef.current = null;
328
333
  }, 2e3);
334
+ await options?.onSuccess?.();
329
335
  return previousSuccessVersion + 1;
330
336
  }, 0);
331
337
  useEffect(() => {
@@ -365,15 +371,6 @@ function useNavigation() {
365
371
  }
366
372
  //#endregion
367
373
  //#region src/runtime/use-island-form.ts
368
- async function readActionErrorBody(response) {
369
- const contentType = response.headers.get("content-type") || "";
370
- try {
371
- if (contentType.includes("application/json")) return await response.json();
372
- return await response.text() || null;
373
- } catch {
374
- return null;
375
- }
376
- }
377
374
  /**
378
375
  * Form hook for island pages. Uses fetch + page reload instead of the
379
376
  * Void Router (which is not available in island mode).
@@ -424,16 +421,12 @@ function useIslandForm(defaults) {
424
421
  setErrors({});
425
422
  setError(null);
426
423
  try {
427
- const response = await fetch(url, {
428
- method,
429
- headers: { "Content-Type": "application/json" },
430
- body: JSON.stringify(data)
431
- });
432
- if (response.redirected) {
433
- window.location.href = response.url;
424
+ const result = await submitIslandAction(url, method, data);
425
+ if (result.kind === "redirect") {
426
+ window.location.href = result.url;
434
427
  return;
435
428
  }
436
- if (response.ok) {
429
+ if (result.kind === "success") {
437
430
  setWasSuccessful(true);
438
431
  setRecentlySuccessful(true);
439
432
  const nextDefaults = { ...data };
@@ -441,22 +434,8 @@ function useIslandForm(defaults) {
441
434
  setDefaultsState(nextDefaults);
442
435
  successTimeoutRef.current = setTimeout(() => setRecentlySuccessful(false), 2e3);
443
436
  window.location.reload();
444
- } else {
445
- const body = await readActionErrorBody(response);
446
- const actionError = new VoidActionError({
447
- body,
448
- status: response.status,
449
- statusText: response.statusText,
450
- url
451
- });
452
- const validationErrors = response.status === 422 ? getValidationErrors(body) : null;
453
- if (validationErrors) {
454
- setErrors(validationErrors);
455
- return;
456
- }
457
- if (categorizeActionError(response.status) === "boundary") throw actionError;
458
- setError(actionError);
459
- }
437
+ } else if (result.kind === "validation") setErrors(result.errors);
438
+ else setError(result.error);
460
439
  } finally {
461
440
  setPending(false);
462
441
  }
package/dist/plugin.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import react from "@vitejs/plugin-react";
2
- import { generateComponentManifest, resolveEffectiveLayoutChain, resolvePagesDir, resolveStaticPageDataBuildOptions, scanPages } from "void/pages";
2
+ import { createPagesPlugin, generateComponentManifest, resolveEffectiveLayoutChain, resolvePagesDir, scanPages } from "void/pages";
3
3
  import { existsSync, readFileSync } from "node:fs";
4
4
  import nodePath from "node:path";
5
5
  import { normalizePath } from "vite";
@@ -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"];
@@ -69,88 +68,28 @@ const REACT_REFRESH_PREAMBLE = `
69
68
  window.__vite_plugin_react_preamble_installed__ = true
70
69
  <\/script>`;
71
70
  function pagesPlugin(pagesDir, options) {
72
- let pageScan = null;
73
- let isDev = false;
74
- let viteConfig;
75
71
  const root = options?.root ?? dirname(pagesDir);
76
- const normalizedPagesDir = normalizePath(pagesDir);
77
72
  const pagesOptimizeEntries = `${relative(root, pagesDir)}/**/*.{js,jsx,ts,tsx}`;
78
- let pendingRescan = null;
79
- const rescanPages = async () => {
80
- pageScan = existsSync(pagesDir) ? await scanPages(root, {
81
- pagesDir,
82
- environment: isDev ? "dev" : "prod"
83
- }) : null;
84
- };
85
- const runRescan = () => {
86
- pendingRescan ??= rescanPages().finally(() => {
87
- pendingRescan = null;
88
- });
89
- return pendingRescan;
90
- };
91
- const invalidatePageModules = (server) => {
92
- for (const env of Object.values(server.environments)) for (const id of ["\0" + RENDERER_ID, "\0" + CLIENT_ENTRY_ID]) {
93
- const mod = env.moduleGraph.getModuleById(id);
94
- if (mod) env.moduleGraph.invalidateModule(mod);
95
- }
96
- };
97
- return {
73
+ return createPagesPlugin(pagesDir, {
98
74
  name: "void-react:pages",
99
- async buildStart() {
100
- await runRescan();
101
- },
102
- resolveId: {
103
- filter: { id: /^virtual:void-pages-(renderer|client)$/ },
104
- handler(id) {
105
- return "\0" + id;
106
- }
107
- },
108
- load: {
109
- filter: { id: /^\0virtual:void-pages-(renderer|client)$/ },
110
- async handler(id) {
111
- if (isDev && pendingRescan) await pendingRescan;
112
- if (!pageScan && existsSync(pagesDir)) await runRescan();
113
- if (id === "\0" + RENDERER_ID) {
114
- if (!pageScan) return "export function renderPage() { throw new Error('No pages found'); }";
115
- return generateRendererModule(pageScan, pagesDir, isDev);
116
- }
117
- if (id === "\0" + CLIENT_ENTRY_ID) {
118
- if (!pageScan) return "";
119
- return generateClientEntry(pageScan, pagesDir, options?.viewTransitions, options?.prefetch, resolveStaticPageDataBuildOptions(root, { publicDir: viteConfig?.publicDir }));
120
- }
121
- }
75
+ root,
76
+ generateRenderer(scan, isDev) {
77
+ return generateRendererModule(scan, pagesDir, isDev);
122
78
  },
123
- configureServer(server) {
124
- const handlePagesChange = async (path, event) => {
125
- const normalizedPath = normalizePath(path);
126
- if (normalizedPath !== normalizedPagesDir && !normalizedPath.startsWith(normalizedPagesDir + "/")) return;
127
- if (event === "change" && !normalizedPath.endsWith(".md")) return;
128
- try {
129
- await runRescan();
130
- } catch (error) {
131
- server.config.logger.error(error instanceof Error ? error.message : String(error), {
132
- error: error instanceof Error ? error : void 0,
133
- timestamp: true
134
- });
135
- return;
136
- }
137
- invalidatePageModules(server);
138
- };
139
- server.watcher.on("add", (path) => handlePagesChange(path, "add"));
140
- server.watcher.on("change", (path) => handlePagesChange(path, "change"));
141
- server.watcher.on("unlink", (path) => handlePagesChange(path, "unlink"));
142
- },
143
- configResolved(config) {
144
- viteConfig = config;
79
+ generateClient(scan, staticPageData) {
80
+ return generateClientEntry(scan, pagesDir, options?.viewTransitions, options?.prefetch, staticPageData);
145
81
  },
146
- async config(_, env) {
147
- isDev = env.command === "serve";
148
- if (!existsSync(pagesDir)) return;
82
+ async config({ isDev, scan }) {
149
83
  if (isDev) return {
150
84
  resolve: { dedupe: REACT_DEDUPE },
151
85
  optimizeDeps: {
152
86
  exclude: ["@void/react"],
153
- include: CLIENT_OPTIMIZE_DEPS
87
+ entries: clientOptimizeEntries(await scan(), pagesDir, root),
88
+ include: [
89
+ ...CLIENT_OPTIMIZE_DEPS,
90
+ "void/pages-client",
91
+ "void/pages-prefetch"
92
+ ]
154
93
  },
155
94
  ssr: { optimizeDeps: { include: [...CLIENT_OPTIMIZE_DEPS, "react-dom/server"] } },
156
95
  environments: { [WORKER_ENVIRONMENT_NAME]: { optimizeDeps: {
@@ -159,8 +98,7 @@ function pagesPlugin(pagesDir, options) {
159
98
  include: WORKER_OPTIMIZE_DEPS
160
99
  } } }
161
100
  };
162
- await runRescan();
163
- if (!pageScan?.pages.some((page) => !page.island)) return;
101
+ if (!(await scan())?.pages.some((page) => !page.island)) return;
164
102
  return {
165
103
  resolve: { dedupe: REACT_DEDUPE },
166
104
  build: {
@@ -169,7 +107,35 @@ function pagesPlugin(pagesDir, options) {
169
107
  }
170
108
  };
171
109
  }
172
- };
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, "\\$&"));
173
139
  }
174
140
  function generateRendererModule(scan, pagesDir, isDev) {
175
141
  const preambleScript = isDev ? REACT_REFRESH_PREAMBLE : "";
@@ -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];
@@ -349,7 +350,7 @@ function App({ engine, router, viewTransitions, navigationFallback: NavigationFa
349
350
  pendingVisitSettlers.delete(prepared.id);
350
351
  if (activePendingNavigationId === prepared.id) activePendingNavigationId = null;
351
352
  dispatch({
352
- type: NavigationFallback ? "abandon" : "finish",
353
+ type: "abandon",
353
354
  resource: prepared,
354
355
  location: currentUrl()
355
356
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@void/react",
3
- "version": "1.3.0",
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.3.0"
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.3.0"
59
+ "void": "1.4.0"
59
60
  },
60
61
  "scripts": {
61
62
  "build": "vp pack",