@void/vue 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,4 +1,4 @@
1
- import { ActionClient, NavigationState, VisitOptions, VoidActionError, VoidRouter } from "void/pages-client";
1
+ import { ActionClient, FormResetOptions, NavigationState, VisitOptions, VoidActionError, VoidRouter } from "void/pages-client";
2
2
  import { Component } from "vue";
3
3
  import { CloudContextVariables, DeferredState as Deferred, InferProps } from "void";
4
4
  import { ActionFormOptions, ActionUrl, ResolveActionBody } from "void/routes";
@@ -33,7 +33,9 @@ export declare function useForm<U extends ActionUrl & string>(url: U, defaults:
33
33
  put(opts?: FormSubmitOptions): Promise<void>;
34
34
  patch(opts?: FormSubmitOptions): Promise<void>;
35
35
  delete(opts?: FormSubmitOptions): Promise<void>;
36
+ reset(options: FormResetOptions, ...fields: ReadonlyArray<keyof ResolveActionBody<U> & string>): void;
36
37
  reset(...fields: ReadonlyArray<keyof ResolveActionBody<U> & string>): void;
38
+ setError(field: keyof ResolveActionBody<U> & string, message: string): void;
37
39
  clearErrors(...fields: ReadonlyArray<keyof ResolveActionBody<U> & string>): void;
38
40
  clearError(): void;
39
41
  };
package/dist/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
- import { VoidActionError, categorizeActionError, createActionClient, formErrorMessage, formRetryMessage, formSubmissionBlocked, getValidationErrors, idleNavigationState, interpolateRouteParams, isAbortError, isEqualFormValue, isModifiedEvent, mergeDataIntoHref, scheduleFormRetry, ssrProxy, submitAction } from "void/pages-client";
2
- import { computed, defineComponent, h, inject, onMounted, onUnmounted, reactive, ref, watch } from "vue";
1
+ import { cloneFormValue, createActionClient, formErrorMessage, formRetryMessage, formSubmissionBlocked, getValidationErrors, idleNavigationState, interpolateRouteParams, isAbortError, isEqualFormValue, isModifiedEvent, mergeDataIntoHref, parseFormResetArgs, scheduleFormRetry, ssrProxy, submitAction, submitIslandAction } from "void/pages-client";
2
+ import { computed, defineComponent, h, inject, onMounted, onUnmounted, reactive, ref, shallowRef, watch } from "vue";
3
3
  //#region src/runtime/form.ts
4
4
  const Form = defineComponent({
5
5
  name: "VoidForm",
@@ -241,28 +241,36 @@ function useShared() {
241
241
  //#endregion
242
242
  //#region src/runtime/use-form.ts
243
243
  function useForm(url, defaults, options) {
244
- const formDefaults = defaults;
244
+ const initialDefaults = cloneFormValue(defaults);
245
245
  const router = inject("__void_router", null);
246
- const data = reactive({ ...formDefaults });
246
+ const data = reactive(cloneFormValue(initialDefaults));
247
247
  const errors = reactive({});
248
248
  const error = ref(null);
249
249
  const pending = ref(false);
250
250
  const wasSuccessful = ref(false);
251
251
  const recentlySuccessful = ref(false);
252
- let defaults_ = { ...formDefaults };
252
+ const defaults_ = shallowRef(initialDefaults);
253
253
  let successTimeout = null;
254
- const hasChanges = ref(false);
255
- watch(() => ({ ...data }), (val) => {
256
- hasChanges.value = Object.keys(defaults_).some((key) => !isEqualFormValue(val[key], defaults_[key]));
257
- }, { deep: true });
258
- function reset(...fields) {
259
- if (fields.length === 0) Object.assign(data, { ...defaults_ });
260
- else for (const field of fields) data[field] = defaults_[field];
254
+ let dataSnapshot = cloneFormValue(initialDefaults);
255
+ watch(() => cloneFormValue(data), (val) => {
256
+ for (const key of /* @__PURE__ */ new Set([...Object.keys(dataSnapshot), ...Object.keys(val)])) if (!isEqualFormValue(val[key], dataSnapshot[key])) delete errors[key];
257
+ dataSnapshot = val;
258
+ }, { flush: "sync" });
259
+ const hasChanges = computed(() => Object.keys(defaults_.value).some((key) => !isEqualFormValue(data[key], defaults_.value[key])));
260
+ function reset(...args) {
261
+ const { initial, fields } = parseFormResetArgs(args);
262
+ const source = initial ? initialDefaults : defaults_.value;
263
+ if (fields.length === 0) Object.assign(data, cloneFormValue(source));
264
+ else for (const field of fields) data[field] = cloneFormValue(source[field]);
265
+ dataSnapshot = cloneFormValue(data);
261
266
  }
262
267
  function clearErrors(...fields) {
263
268
  if (fields.length === 0) for (const key of Object.keys(errors)) delete errors[key];
264
269
  else for (const field of fields) delete errors[field];
265
270
  }
271
+ function setError(field, message) {
272
+ errors[field] = message;
273
+ }
266
274
  function clearError() {
267
275
  error.value = null;
268
276
  }
@@ -288,7 +296,10 @@ function useForm(url, defaults, options) {
288
296
  }
289
297
  wasSuccessful.value = true;
290
298
  recentlySuccessful.value = true;
291
- defaults_ = { ...data };
299
+ if (options?.resetOnSuccess) {
300
+ Object.assign(data, cloneFormValue(initialDefaults));
301
+ defaults_.value = cloneFormValue(initialDefaults);
302
+ } else defaults_.value = cloneFormValue(data);
292
303
  successTimeout = setTimeout(() => {
293
304
  recentlySuccessful.value = false;
294
305
  }, 2e3);
@@ -308,6 +319,7 @@ function useForm(url, defaults, options) {
308
319
  wasSuccessful,
309
320
  recentlySuccessful,
310
321
  reset,
322
+ setError,
311
323
  clearErrors,
312
324
  clearError,
313
325
  post: (opts) => submit("POST", opts),
@@ -323,15 +335,6 @@ function useNavigation() {
323
335
  }
324
336
  //#endregion
325
337
  //#region src/runtime/use-island-form.ts
326
- async function readActionErrorBody(response) {
327
- const contentType = response.headers.get("content-type") || "";
328
- try {
329
- if (contentType.includes("application/json")) return await response.json();
330
- return await response.text() || null;
331
- } catch {
332
- return null;
333
- }
334
- }
335
338
  /**
336
339
  * Form composable for island pages. Uses fetch + page reload instead of the
337
340
  * Void Router (which is not available in island mode).
@@ -368,16 +371,12 @@ function useIslandForm(defaults) {
368
371
  clearErrors();
369
372
  clearError();
370
373
  try {
371
- const response = await fetch(url, {
372
- method,
373
- headers: { "Content-Type": "application/json" },
374
- body: JSON.stringify(data)
375
- });
376
- if (response.redirected) {
377
- window.location.href = response.url;
374
+ const result = await submitIslandAction(url, method, data);
375
+ if (result.kind === "redirect") {
376
+ window.location.href = result.url;
378
377
  return;
379
378
  }
380
- if (response.ok) {
379
+ if (result.kind === "success") {
381
380
  wasSuccessful.value = true;
382
381
  recentlySuccessful.value = true;
383
382
  defaults_ = { ...data };
@@ -385,22 +384,8 @@ function useIslandForm(defaults) {
385
384
  recentlySuccessful.value = false;
386
385
  }, 2e3);
387
386
  window.location.reload();
388
- } else {
389
- const body = await readActionErrorBody(response);
390
- const actionError = new VoidActionError({
391
- body,
392
- status: response.status,
393
- statusText: response.statusText,
394
- url
395
- });
396
- const validationErrors = response.status === 422 ? getValidationErrors(body) : null;
397
- if (validationErrors) {
398
- Object.assign(errors, validationErrors);
399
- return;
400
- }
401
- if (categorizeActionError(response.status) === "boundary") throw actionError;
402
- error.value = actionError;
403
- }
387
+ } else if (result.kind === "validation") Object.assign(errors, result.errors);
388
+ else error.value = result.error;
404
389
  } finally {
405
390
  pending.value = false;
406
391
  }
package/dist/plugin.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import vue from "@vitejs/plugin-vue";
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, readdirSync } from "node:fs";
4
4
  import nodePath from "node:path";
5
5
  import { normalizePath } from "vite";
@@ -89,7 +89,10 @@ function islandsPlugin(pagesDir, options) {
89
89
  },
90
90
  async buildStart() {
91
91
  if (existsSync(pagesDir)) {
92
- pageScan = await scanPages(root, { pagesDir });
92
+ pageScan = await scanPages(root, {
93
+ pagesDir,
94
+ environment: isDev ? "dev" : "prod"
95
+ });
93
96
  const filesToScan = [
94
97
  ...pageScan.pages.map((page) => ({
95
98
  filePath: page.componentPath,
@@ -310,8 +313,36 @@ initIslands(islands, hydrateIsland);
310
313
  }
311
314
  //#endregion
312
315
  //#region src/plugin-pages.ts
313
- const RENDERER_ID = "virtual:void-pages-renderer";
314
316
  const CLIENT_ENTRY_ID = "virtual:void-pages-client";
317
+ /** Runtime helper injected into generated SSR and client page bundles. */
318
+ const PATCH_PROPS_RUNTIME = `
319
+ function hasRuntimePropsDeclaration(comp) {
320
+ const declared = comp.props;
321
+ if (declared == null) return false;
322
+ if (Array.isArray(declared)) return declared.length > 0;
323
+ if (typeof declared === "object") return Object.keys(declared).length > 0;
324
+ return false;
325
+ }
326
+
327
+ function patchProps(comp, props) {
328
+ if (comp.__voidPatched) return;
329
+ const keys = Object.keys(props);
330
+ if (hasRuntimePropsDeclaration(comp)) {
331
+ const declared = comp.props;
332
+ // defineModel() can declare runtime props even after typed loader props are stripped.
333
+ const missing = keys.filter((key) =>
334
+ Array.isArray(declared) ? !declared.includes(key) : !Object.hasOwn(declared, key)
335
+ );
336
+ if (missing.length === 0) return;
337
+ comp.props = Array.isArray(declared)
338
+ ? [...declared, ...missing]
339
+ : { ...Object.fromEntries(missing.map((key) => [key, null])), ...declared };
340
+ } else {
341
+ comp.props = keys;
342
+ }
343
+ comp.__voidPatched = true;
344
+ }
345
+ `;
315
346
  function isVuePageFile(id, pagesDir) {
316
347
  if (!id.startsWith(pagesDir + "/")) return false;
317
348
  const rel = id.slice(pagesDir.length + 1);
@@ -357,34 +388,14 @@ function hasNonIslandPages(dir) {
357
388
  return false;
358
389
  }
359
390
  function pagesPlugin(pagesDir, options) {
360
- let pageScan = null;
361
- let isDev = false;
362
- let viteConfig;
363
- const root = options?.root ?? dirname(pagesDir);
364
391
  const normalizedPagesDir = normalizePath(pagesDir);
365
- let pendingRescan = null;
366
- const rescanPages = async () => {
367
- pageScan = existsSync(pagesDir) ? await scanPages(root, { pagesDir }) : null;
368
- };
369
- const runRescan = () => {
370
- pendingRescan ??= rescanPages().finally(() => {
371
- pendingRescan = null;
372
- });
373
- return pendingRescan;
374
- };
375
- const invalidatePageModules = (server) => {
376
- for (const env of Object.values(server.environments)) for (const id of ["\0" + RENDERER_ID, "\0" + CLIENT_ENTRY_ID]) {
377
- const mod = env.moduleGraph.getModuleById(id);
378
- if (mod) env.moduleGraph.invalidateModule(mod);
379
- }
380
- };
381
392
  return [{
382
393
  name: "void-vue:pages-transform",
383
394
  enforce: "pre",
384
395
  transform: {
385
396
  filter: { id: /\.vue$/ },
386
397
  handler(code, id) {
387
- if (!isVuePageFile(id, pagesDir)) return;
398
+ if (!isVuePageFile(normalizePath(id), normalizedPagesDir)) return;
388
399
  const transformed = stripDefinePropsTypeArgument(code);
389
400
  if (!transformed) return;
390
401
  return {
@@ -396,7 +407,7 @@ function pagesPlugin(pagesDir, options) {
396
407
  handleHotUpdate: {
397
408
  order: "pre",
398
409
  handler(ctx) {
399
- if (!isVuePageFile(ctx.file, pagesDir)) return;
410
+ if (!isVuePageFile(normalizePath(ctx.file), normalizedPagesDir)) return;
400
411
  const read = ctx.read;
401
412
  ctx.read = async () => {
402
413
  const code = await read();
@@ -404,68 +415,24 @@ function pagesPlugin(pagesDir, options) {
404
415
  };
405
416
  }
406
417
  }
407
- }, {
418
+ }, createPagesPlugin(pagesDir, {
408
419
  name: "void-vue:pages",
409
- async buildStart() {
410
- await runRescan();
420
+ root: options?.root,
421
+ generateRenderer(scan) {
422
+ return generateRendererModule(scan, pagesDir);
411
423
  },
412
- resolveId: {
413
- filter: { id: /^virtual:void-pages-(renderer|client)$/ },
414
- handler(id) {
415
- return "\0" + id;
416
- }
424
+ generateClient(scan, staticPageData) {
425
+ return generateClientEntry(scan, pagesDir, options?.viewTransitions, options?.prefetch, staticPageData);
417
426
  },
418
- load: {
419
- filter: { id: /^\0virtual:void-pages-(renderer|client)$/ },
420
- async handler(id) {
421
- if (isDev && pendingRescan) await pendingRescan;
422
- if (!pageScan && existsSync(pagesDir)) await runRescan();
423
- if (id === "\0" + RENDERER_ID) {
424
- if (!pageScan) return "export function renderPage() { throw new Error('No pages found'); }";
425
- return generateRendererModule(pageScan, pagesDir);
426
- }
427
- if (id === "\0" + CLIENT_ENTRY_ID) {
428
- if (!pageScan) return "";
429
- return generateClientEntry(pageScan, pagesDir, options?.viewTransitions, options?.prefetch, resolveStaticPageDataBuildOptions(root, { publicDir: viteConfig?.publicDir }));
430
- }
431
- }
432
- },
433
- configureServer(server) {
434
- const handlePagesChange = async (path, event) => {
435
- const normalizedPath = normalizePath(path);
436
- if (normalizedPath !== normalizedPagesDir && !normalizedPath.startsWith(normalizedPagesDir + "/")) return;
437
- if (event === "change" && !normalizedPath.endsWith(".md")) return;
438
- try {
439
- await runRescan();
440
- } catch (error) {
441
- server.config.logger.error(error instanceof Error ? error.message : String(error), {
442
- error: error instanceof Error ? error : void 0,
443
- timestamp: true
444
- });
445
- return;
446
- }
447
- invalidatePageModules(server);
448
- };
449
- server.watcher.on("add", (path) => handlePagesChange(path, "add"));
450
- server.watcher.on("change", (path) => handlePagesChange(path, "change"));
451
- server.watcher.on("unlink", (path) => handlePagesChange(path, "unlink"));
452
- },
453
- configResolved(config) {
454
- viteConfig = config;
455
- },
456
- config(_, env) {
457
- if (!existsSync(pagesDir)) return;
458
- if (env.command === "serve") {
459
- isDev = true;
460
- return { optimizeDeps: { include: ["vue"] } };
461
- }
427
+ config({ isDev }) {
428
+ if (isDev) return { optimizeDeps: { include: ["vue"] } };
462
429
  if (!hasNonIslandPages(pagesDir)) return;
463
430
  return { environments: { client: { build: {
464
431
  manifest: true,
465
432
  rollupOptions: { input: { "pages-client": CLIENT_ENTRY_ID } }
466
433
  } } } };
467
434
  }
468
- }];
435
+ })];
469
436
  }
470
437
  function generateRendererModule(scan, pagesDir) {
471
438
  return `
@@ -477,11 +444,7 @@ import { serializePageData } from "void/pages-server";
477
444
 
478
445
  ${generateComponentManifest(scan, pagesDir)}
479
446
 
480
- function patchProps(comp, props) {
481
- if (comp.__voidPatched) return;
482
- comp.props = Object.keys(props);
483
- comp.__voidPatched = true;
484
- }
447
+ ${PATCH_PROPS_RUNTIME}
485
448
 
486
449
  export async function renderPage(pageObj, assetTags) {
487
450
  const PageComponent = (await components[pageObj.component]()).default;
@@ -545,11 +508,7 @@ const currentPageKey = ref(0);
545
508
  const routeUrl = ref(window.location.pathname + window.location.search + window.location.hash);
546
509
  const navigation = reactive(idleNavigationState());
547
510
 
548
- function patchProps(comp, props) {
549
- if (comp.__voidPatched) return;
550
- comp.props = Object.keys(props);
551
- comp.__voidPatched = true;
552
- }
511
+ ${PATCH_PROPS_RUNTIME}
553
512
 
554
513
  function replaceRecord(target, source) {
555
514
  for (const key of Object.keys(target)) delete target[key];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@void/vue",
3
- "version": "1.2.1",
3
+ "version": "1.4.0",
4
4
  "description": "Vue pages, server rendering, forms, and navigation for Void.",
5
5
  "homepage": "https://void.cloud",
6
6
  "bugs": {
@@ -41,12 +41,12 @@
41
41
  "pathslash": "^0.1.0",
42
42
  "vite": "npm:@voidzero-dev/vite-plus-core@1.3.0",
43
43
  "vite-plus": "1.3.0",
44
- "void": "1.2.1",
44
+ "void": "1.4.0",
45
45
  "vue": "^3.5.44"
46
46
  },
47
47
  "peerDependencies": {
48
48
  "vite": "^8.0.0",
49
- "void": "1.2.1",
49
+ "void": "1.4.0",
50
50
  "vue": "^3.5.0"
51
51
  },
52
52
  "scripts": {