@void/svelte 1.5.0 → 1.6.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/plugin.d.mts CHANGED
@@ -1,13 +1,9 @@
1
1
  import { svelte } from "@sveltejs/vite-plugin-svelte";
2
+ import { PrefetchConfig } from "void/pages";
2
3
  import { Plugin } from "vite";
3
- type PrefetchConfig = {
4
- hoverDelay?: number;
5
- cacheFor?: number | string | [string, string];
6
- };
7
4
  export type VoidSvelteOptions = Readonly<{
8
5
  svelte?: Parameters<typeof svelte>[0];
9
6
  viewTransitions?: boolean;
10
7
  prefetch?: PrefetchConfig;
11
8
  }>;
12
- export declare function voidSvelte(options?: VoidSvelteOptions): Array<Plugin>;
13
- export {};
9
+ export declare function voidSvelte(options?: VoidSvelteOptions): Array<Plugin>;
package/dist/plugin.mjs CHANGED
@@ -1,10 +1,9 @@
1
1
  import { svelte } from "@sveltejs/vite-plugin-svelte";
2
- import { createPagesPlugin, generateComponentManifest, resolveEffectiveLayoutChain, resolvePagesDir, scanPages } from "void/pages";
2
+ import { createPagesPlugin, generateComponentManifest, resolvePagesDir, resolvePrefetchConfig, scanPages } from "void/pages";
3
3
  import { existsSync, readFileSync, readdirSync } from "node:fs";
4
4
  import nodePath from "node:path";
5
5
  import { preprocess } from "svelte/compiler";
6
- import { normalizePath } from "vite";
7
- import { ISLANDS_CLIENT_ENTRY_ID, ISLANDS_RENDERER_ID, computePagesNeedingClientJS, editIslandSource, findIslandImports, findStaticCssImports, hasIslandPages, islandStylesPlugin, recordIslandStyles, recordedIslandStyles, resolveSpecifier, rewriteCssImportForVirtualModule, watchPagesForRescan } from "void/pages-islands-plugin";
6
+ import { ISLANDS_CLIENT_ENTRY_ID, ISLANDS_RENDERER_ID, computePagesNeedingClientJS, createIslandManifest, editIslandSource, findIslandImports, generateIslandClientEntry, islandCssFiles, islandStylesPlugin, islandsBuildConfig, mdClientScriptIds, recordIslandStyles, recordedIslandStyles, watchPagesForRescan } from "void/pages-islands-plugin";
8
7
  // ../../node_modules/.pnpm/pathslash@0.1.0/node_modules/pathslash/dist/index.mjs
9
8
  const BACKSLASH_RE = /\\/g;
10
9
  const isWindows = process.platform === "win32";
@@ -49,6 +48,18 @@ const COMPONENT_EXTENSIONS = [
49
48
  ".ts",
50
49
  ".js"
51
50
  ];
51
+ const HYDRATE_ISLAND = `
52
+ async function hydrateIsland(el, mod, props, frontmatter) {
53
+ const { hydrate } = await import("svelte");
54
+ const Component = mod.default || mod;
55
+ const context = new Map([["__void_frontmatter", frontmatter]]);
56
+ hydrate(Component, { target: el, props, context });
57
+ el.setAttribute("data-hydrated", "true");
58
+ }`;
59
+ /** A Svelte component has at most one top-level `<style>`. */
60
+ function styleBlockImports(file, content) {
61
+ return /<style\b/.test(content) ? [`import ${JSON.stringify(file + ".css?island-style")};`] : [];
62
+ }
52
63
  /**
53
64
  * Prefix for virtual island wrapper modules. Must NOT use the `\0` prefix
54
65
  * because `@sveltejs/vite-plugin-svelte` skips `\0`-prefixed modules in
@@ -58,27 +69,10 @@ const WRAPPER_PREFIX = "/@void-island-wrap/";
58
69
  function islandsPlugin(pagesDir, options) {
59
70
  let pageScan = null;
60
71
  const root = options?.root ?? dirname(pagesDir);
61
- const islandManifest = /* @__PURE__ */ new Map();
62
- const islandCssFiles = /* @__PURE__ */ new Set();
72
+ const islandManifest = createIslandManifest(root, COMPONENT_EXTENSIONS);
73
+ let cssFiles = /* @__PURE__ */ new Set();
63
74
  let isDev = false;
64
75
  let resolvedConfig = null;
65
- function registerIsland(spec, importerDir, hydrate) {
66
- const absPath = resolveSpecifier(spec, importerDir, COMPONENT_EXTENSIONS);
67
- if (!absPath) return {
68
- absPath,
69
- islandId: spec
70
- };
71
- const islandId = normalizePath(relative(root, absPath)).replace(/\.[^.]+$/, "");
72
- islandManifest.set(islandId, {
73
- componentPath: absPath,
74
- islandId,
75
- hydrate
76
- });
77
- return {
78
- absPath,
79
- islandId
80
- };
81
- }
82
76
  return [
83
77
  {
84
78
  name: "void-svelte:islands",
@@ -98,33 +92,8 @@ function islandsPlugin(pagesDir, options) {
98
92
  pagesDir,
99
93
  environment: isDev ? "dev" : "prod"
100
94
  });
101
- const filesToScan = [
102
- ...pageScan.pages.map((page) => ({
103
- filePath: page.componentPath,
104
- island: page.island
105
- })),
106
- ...pageScan.layouts.map((layout) => ({
107
- filePath: layout.filePath,
108
- island: layout.island
109
- })),
110
- ...pageScan.namedLayouts.map((namedLayout) => ({
111
- filePath: namedLayout.filePath,
112
- island: namedLayout.island
113
- }))
114
- ];
115
- for (const entry of filesToScan) {
116
- if (!entry.island) continue;
117
- const filePath = resolve(pagesDir, entry.filePath);
118
- const code = readFileSync(filePath, "utf-8");
119
- const importerDir = dirname(filePath);
120
- for (const islandImport of findIslandImports(code, filePath)) registerIsland(islandImport.specifier, importerDir, islandImport.hydrate);
121
- }
122
- for (const page of pageScan.pages) {
123
- if (!page.island) continue;
124
- if (page.componentPath.endsWith(".svelte")) islandCssFiles.add(normalizePath(resolve(pagesDir, page.componentPath)));
125
- const layoutChain = resolveEffectiveLayoutChain(page, pageScan.layouts, pageScan.namedLayouts);
126
- for (const layout of layoutChain) islandCssFiles.add(normalizePath(resolve(pagesDir, layout.filePath)));
127
- }
95
+ islandManifest.registerPages(pageScan, pagesDir);
96
+ cssFiles = islandCssFiles(pageScan, pagesDir, /\.svelte$/);
128
97
  }
129
98
  },
130
99
  buildEnd() {
@@ -142,14 +111,21 @@ function islandsPlugin(pagesDir, options) {
142
111
  handler(id) {
143
112
  if (id === "\0" + ISLANDS_RENDERER_ID) {
144
113
  if (!pageScan) return "export function renderIslandPage() { throw new Error('No pages found'); }";
145
- const mdPlugin = resolvedConfig?.plugins?.find((plugin) => plugin.name === "void-md");
146
- const mdScriptIds = new Set(mdPlugin?.api?.getClientScripts?.()?.keys() ?? []);
114
+ const mdScriptIds = mdClientScriptIds(resolvedConfig);
147
115
  const needsJS = computePagesNeedingClientJS(pageScan, pagesDir, resolvedConfig);
148
116
  return generateIslandRenderer(pageScan, pagesDir, mdScriptIds, needsJS);
149
117
  }
150
118
  if (id === "\0" + ISLANDS_CLIENT_ENTRY_ID) {
151
119
  if (!pageScan) return "";
152
- return generateIslandClientEntry(islandManifest, islandCssFiles, recordedIslandStyles(pagesDir), islandManifest.size > 0 || computePagesNeedingClientJS(pageScan, pagesDir, resolvedConfig).size > 0, isDev);
120
+ return generateIslandClientEntry({
121
+ manifest: islandManifest,
122
+ cssFiles,
123
+ styles: recordedIslandStyles(pagesDir),
124
+ needsRuntime: islandManifest.entries.size > 0 || computePagesNeedingClientJS(pageScan, pagesDir, resolvedConfig).size > 0,
125
+ isDev,
126
+ styleBlockImports,
127
+ hydrateIsland: HYDRATE_ISLAND
128
+ });
153
129
  }
154
130
  if (id.startsWith(WRAPPER_PREFIX)) return generateWrapperComponent(id);
155
131
  }
@@ -164,7 +140,7 @@ function islandsPlugin(pagesDir, options) {
164
140
  const name = islandImport.importName;
165
141
  const spec = islandImport.specifier;
166
142
  const strategy = islandImport.hydrate;
167
- const { absPath, islandId } = registerIsland(spec, importerDir, strategy);
143
+ const { absPath, islandId } = islandManifest.register(spec, importerDir, strategy);
168
144
  const wrapperUrl = WRAPPER_PREFIX + encodeURIComponent(islandId) + ".svelte?src=" + encodeURIComponent(absPath || spec) + "&island=" + encodeURIComponent(islandId) + "&hydrate=" + encodeURIComponent(strategy);
169
145
  const replacement = `import ${name} from ${JSON.stringify(wrapperUrl)}`;
170
146
  edits.push({
@@ -179,10 +155,7 @@ function islandsPlugin(pagesDir, options) {
179
155
  async config(_, env) {
180
156
  if (!existsSync(pagesDir)) return;
181
157
  if (env.command === "serve") return { optimizeDeps: { include: ["svelte"] } };
182
- if (await hasIslandPages(root, pagesDir)) return { environments: { client: { build: {
183
- manifest: true,
184
- rollupOptions: { input: { "islands-client": ISLANDS_CLIENT_ENTRY_ID } }
185
- } } } };
158
+ return islandsBuildConfig(root, pagesDir);
186
159
  }
187
160
  },
188
161
  {
@@ -297,35 +270,6 @@ export async function renderIslandPage(pageObj, assetTags) {
297
270
  }
298
271
  `;
299
272
  }
300
- function generateIslandClientEntry(manifest, cssFiles, styles, needsRuntime, isDev) {
301
- const entries = [...manifest.values()];
302
- const uniqueIslands = /* @__PURE__ */ new Map();
303
- for (const entry of entries) uniqueIslands.set(entry.islandId, entry.componentPath);
304
- const lines = [];
305
- if (!isDev && styles) for (const id of styles) lines.push(`import ${JSON.stringify(id)};`);
306
- else if (!isDev) for (const file of cssFiles) {
307
- const content = readFileSync(file, "utf-8");
308
- if (/<style\b/.test(content)) lines.push(`import ${JSON.stringify(file + ".css?island-style")};`);
309
- for (const item of findStaticCssImports(content, file)) lines.push(rewriteCssImportForVirtualModule(item.statement, item.specifier, file));
310
- }
311
- if (!needsRuntime && !isDev) return lines.join("\n");
312
- lines.push("import { initIslands } from \"void/pages-client\";");
313
- lines.push("const islands = {");
314
- for (const [id, path] of uniqueIslands) lines.push(` ${JSON.stringify(id)}: () => import(${JSON.stringify(path)}),`);
315
- lines.push("};");
316
- lines.push(`
317
- async function hydrateIsland(el, mod, props, frontmatter) {
318
- const { hydrate } = await import("svelte");
319
- const Component = mod.default || mod;
320
- const context = new Map([["__void_frontmatter", frontmatter]]);
321
- hydrate(Component, { target: el, props, context });
322
- el.setAttribute("data-hydrated", "true");
323
- }
324
-
325
- initIslands(islands, hydrateIsland);
326
- `);
327
- return lines.join("\n");
328
- }
329
273
  // src/plugin-pages.ts
330
274
  const CLIENT_ENTRY_ID = "virtual:void-pages-client";
331
275
  const SVELTE_OPTIMIZE_DEPS = {
@@ -385,7 +329,7 @@ import { serializePageData } from "void/pages-server";
385
329
 
386
330
  ${generateComponentManifest(scan, pagesDir)}
387
331
 
388
- export async function renderPage(pageObj, assetTags) {
332
+ export async function renderPage(pageObj, assetTags, requestUrl) {
389
333
  const PageComponent = (await components[pageObj.component]()).default;
390
334
  const layoutIds = layoutTree[pageObj.component] || [];
391
335
  const layoutComponents = await Promise.all(
@@ -399,7 +343,7 @@ export async function renderPage(pageObj, assetTags) {
399
343
  pageProps: pageObj.props,
400
344
  shared: pageObj.shared || {},
401
345
  params: pageObj.params || {},
402
- router: createSsrRouter(pageObj.url || "/", pageObj.params || {}),
346
+ router: createSsrRouter(pageObj.url || "/", pageObj.params || {}, requestUrl),
403
347
  routeUrl: pageObj.url || "/",
404
348
  navigation: idleNavigationState(),
405
349
  stores: null,
@@ -424,6 +368,7 @@ export async function renderPage(pageObj, assetTags) {
424
368
  `;
425
369
  }
426
370
  function generateClientEntry(scan, pagesDir, viewTransitions, prefetch, staticPageData) {
371
+ const { defaultStrategy, hoverDelay, cacheFor } = resolvePrefetchConfig(prefetch);
427
372
  return `
428
373
  import { hydrate, mount, tick } from "svelte";
429
374
  import { writable } from "svelte/store";
@@ -467,8 +412,9 @@ const { router, initialPageData, initialComponent, initialLayouts, start, commit
467
412
  staticPageData: ${!!staticPageData?.enabled},
468
413
  staticPageDataFastPath: ${!!staticPageData?.fastPath},
469
414
  viewTransitions: ${!!viewTransitions},
470
- prefetchHoverDelay: ${prefetch?.hoverDelay ?? 75},
471
- prefetchDefaultCacheFor: parseCacheFor(${JSON.stringify(prefetch?.cacheFor ?? "30s")}),
415
+ prefetchHoverDelay: ${hoverDelay},
416
+ prefetchDefaultCacheFor: parseCacheFor(${JSON.stringify(cacheFor)}),
417
+ prefetchDefaultStrategy: ${JSON.stringify(defaultStrategy)},
472
418
  onRouteChange(url) {
473
419
  routeUrl = url;
474
420
  routeStore.set(url);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@void/svelte",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "description": "Svelte pages, server rendering, forms, and navigation for Void.",
5
5
  "homepage": "https://void.cloud",
6
6
  "bugs": {
@@ -43,12 +43,12 @@
43
43
  "svelte": "^5.57.2",
44
44
  "vite": "npm:@voidzero-dev/vite-plus-core@1.4.0",
45
45
  "vite-plus": "1.4.0",
46
- "void": "1.5.0"
46
+ "void": "1.6.0"
47
47
  },
48
48
  "peerDependencies": {
49
49
  "svelte": "^5.0.0",
50
50
  "vite": "^8.0.0",
51
- "void": "1.5.0"
51
+ "void": "1.6.0"
52
52
  },
53
53
  "scripts": {
54
54
  "build": "vp pack",
@@ -28,7 +28,7 @@
28
28
  let params = $state(init.params || {});
29
29
  let routeUrl = $state(init.routeUrl || "/");
30
30
  let navigation = $state(init.navigation || idleNavigationState());
31
- const router = createRouterFacade(init.router, () => routeUrl, () => params);
31
+ const router = init.router ? createRouterFacade(init.router, () => routeUrl, () => params) : null;
32
32
 
33
33
  // Pages and layouts keep the objects returned by useShared() and useParams()
34
34
  // across navigations, so update them in place.
@@ -1,16 +1,17 @@
1
1
  <script lang="ts">
2
- import { getContext, onDestroy, onMount, type Snippet } from "svelte";
2
+ import { getContext, onDestroy, onMount } from "svelte";
3
3
  import {
4
- isModifiedEvent,
5
- mergeDataIntoHref,
6
- type PrefetchOptions,
4
+ handleLinkClick,
5
+ linkActiveAttributes,
6
+ linkPendingAttributes,
7
+ linkPrefetchStrategies,
8
+ observeLinkVisible,
9
+ prefetchLink,
10
+ resolveLink,
11
+ type NavigationState,
7
12
  type VoidRouter,
8
13
  } from "void/pages-client";
9
-
10
- type PrefetchStrategy = "hover" | "click" | "mount" | "visible";
11
- type LinkMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
12
- type NavigateEvent = { preventDefault(): void };
13
- type EventHandler<T extends Event> = (event: T) => void;
14
+ import type { LinkProps } from "./link-props.ts";
14
15
 
15
16
  let {
16
17
  href,
@@ -21,8 +22,9 @@
21
22
  replace = false,
22
23
  reloadDocument = false,
23
24
  viewTransition = undefined,
24
- prefetch: prefetchProp = false,
25
+ prefetch: prefetchProp = undefined,
25
26
  cacheFor = undefined,
27
+ activeMatch = undefined,
26
28
  onNavigate = undefined,
27
29
  children,
28
30
  onclick: onclickProp = undefined,
@@ -33,104 +35,30 @@
33
35
  ontouchstart: onTouchStartProp = undefined,
34
36
  style = undefined,
35
37
  ...rest
36
- }: {
37
- href: string;
38
- method?: LinkMethod | Lowercase<LinkMethod>;
39
- data?: Record<string, unknown>;
40
- preserveScroll?: boolean;
41
- preserveState?: boolean;
42
- replace?: boolean;
43
- reloadDocument?: boolean;
44
- viewTransition?: boolean;
45
- prefetch?: boolean | PrefetchStrategy | Array<PrefetchStrategy>;
46
- cacheFor?: number | string | [string, string];
47
- onNavigate?: (event: NavigateEvent) => void;
48
- children?: Snippet;
49
- onclick?: EventHandler<MouseEvent>;
50
- onmouseenter?: EventHandler<MouseEvent>;
51
- onmouseleave?: EventHandler<MouseEvent>;
52
- onfocus?: EventHandler<FocusEvent>;
53
- onmousedown?: EventHandler<MouseEvent>;
54
- ontouchstart?: EventHandler<TouchEvent>;
55
- style?: string;
56
- [key: string]: unknown;
57
- } = $props();
38
+ }: LinkProps = $props();
58
39
 
59
40
  const router = getContext<VoidRouter>("__void_router");
60
-
61
- function getStrategies(): Array<PrefetchStrategy> {
62
- if (prefetchProp !== false && !isGet) {
63
- throw new Error(
64
- 'Link: prefetch only supports GET links. Remove `prefetch` or use method="GET".',
65
- );
66
- }
67
- if (reloadDocument && !isGet) {
68
- throw new Error("Link: reloadDocument only supports GET links.");
69
- }
70
- if (prefetchProp === false || reloadDocument) {
71
- return [];
72
- }
73
- if (prefetchProp === true) {
74
- return ["hover"];
75
- }
76
- if (typeof prefetchProp === "string") {
77
- return [prefetchProp];
78
- }
79
- return prefetchProp;
80
- }
41
+ const navigation = getContext<{ value: NavigationState } | undefined>("__void_navigation");
81
42
 
82
43
  function doPrefetch() {
83
- if (!router?.prefetch) {
84
- return;
85
- }
86
- const current = window.location.pathname + window.location.search;
87
- if (hrefWithData === current) {
88
- return;
89
- }
90
- const options: PrefetchOptions = { method: normalizedMethod };
91
- if (cacheFor !== undefined) {
92
- options.cacheFor = cacheFor;
93
- }
94
- router.prefetch(hrefWithData, options);
44
+ prefetchLink(router, link.href, cacheFor);
95
45
  }
96
46
 
97
47
  function onClick(e: MouseEvent) {
98
48
  onclickProp?.(e);
99
- if (reloadDocument || !router || e.defaultPrevented || isModifiedEvent(e)) {
100
- return;
101
- }
102
- if ((e.currentTarget as HTMLElement).hasAttribute("download")) {
103
- return;
104
- }
105
- const url = new URL(hrefWithData, window.location.origin);
106
- if (url.origin !== window.location.origin) {
107
- return;
108
- }
109
-
110
- let navigatePrevented = false;
111
- onNavigate?.({
112
- preventDefault() {
113
- navigatePrevented = true;
114
- },
115
- });
116
- if (navigatePrevented) {
117
- e.preventDefault();
118
- return;
119
- }
120
-
121
- e.preventDefault();
122
- router.visit(hrefWithData, {
123
- method: normalizedMethod,
124
- data: isGet ? undefined : data,
49
+ handleLinkClick(e, router, link, {
50
+ data,
125
51
  preserveScroll,
126
52
  preserveState,
127
53
  replace,
54
+ reloadDocument,
128
55
  viewTransition,
56
+ onNavigate,
129
57
  });
130
58
  }
131
59
 
132
60
  let hoverTimer: ReturnType<typeof setTimeout> | null = null;
133
- let observer: IntersectionObserver | null = null;
61
+ let stopObserving: (() => void) | null = null;
134
62
  let el = $state<HTMLElement | null>(null);
135
63
 
136
64
  function onMouseEnter(e: MouseEvent) {
@@ -178,17 +106,7 @@
178
106
  doPrefetch();
179
107
  }
180
108
  if (strategies.includes("visible") && el) {
181
- observer = new IntersectionObserver(
182
- (entries) => {
183
- if (entries.some((e) => e.isIntersecting)) {
184
- doPrefetch();
185
- observer?.disconnect();
186
- observer = null;
187
- }
188
- },
189
- { rootMargin: "0px" },
190
- );
191
- observer.observe(el);
109
+ stopObserving = observeLinkVisible(el, doPrefetch);
192
110
  }
193
111
  });
194
112
 
@@ -196,25 +114,34 @@
196
114
  if (hoverTimer) {
197
115
  clearTimeout(hoverTimer);
198
116
  }
199
- if (observer) {
200
- observer.disconnect();
201
- observer = null;
202
- }
117
+ stopObserving?.();
203
118
  });
204
119
 
205
- const normalizedMethod = $derived(method.toUpperCase() as LinkMethod);
206
- const isGet = $derived(normalizedMethod === "GET");
207
- const hrefWithData = $derived(isGet ? mergeDataIntoHref(href, data) : href);
208
- const strategies = $derived(getStrategies());
120
+ const link = $derived(
121
+ resolveLink({ href, method, data, prefetch: prefetchProp, reloadDocument }),
122
+ );
123
+ const strategies = $derived(
124
+ linkPrefetchStrategies(
125
+ { prefetch: prefetchProp, reloadDocument },
126
+ link,
127
+ router?._prefetchDefaultStrategy,
128
+ ),
129
+ );
130
+ // Reading router.url tracks the route URL, so this follows navigations.
131
+ const activeAttributes = $derived(linkActiveAttributes(router, link, activeMatch));
132
+ // Reading navigation.value tracks visits as they start and end.
133
+ const pendingAttributes = $derived(linkPendingAttributes(router, navigation?.value, link));
209
134
  const resolvedStyle = $derived(
210
135
  style ? `touch-action: manipulation; ${style}` : "touch-action: manipulation",
211
136
  );
212
137
  </script>
213
138
 
214
- {#if isGet}
139
+ {#if link.isGet}
215
140
  <a
216
141
  bind:this={el}
217
- href={hrefWithData}
142
+ href={link.href}
143
+ {...activeAttributes}
144
+ {...pendingAttributes}
218
145
  {...rest}
219
146
  style={resolvedStyle}
220
147
  onclick={onClick}
@@ -232,6 +159,7 @@
232
159
  <button
233
160
  bind:this={el}
234
161
  type="button"
162
+ {...pendingAttributes}
235
163
  {...rest}
236
164
  style={resolvedStyle}
237
165
  onclick={onClick}
@@ -0,0 +1,16 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { BaseLinkProps } from 'void/pages-client';
3
+
4
+ type EventHandler<T extends Event> = (event: T) => void;
5
+
6
+ export type LinkProps = BaseLinkProps & {
7
+ children?: Snippet;
8
+ onclick?: EventHandler<MouseEvent>;
9
+ onmouseenter?: EventHandler<MouseEvent>;
10
+ onmouseleave?: EventHandler<MouseEvent>;
11
+ onfocus?: EventHandler<FocusEvent>;
12
+ onmousedown?: EventHandler<MouseEvent>;
13
+ ontouchstart?: EventHandler<TouchEvent>;
14
+ style?: string;
15
+ [key: string]: unknown;
16
+ };
@@ -9,65 +9,32 @@ import {
9
9
  parseFormResetArgs,
10
10
  submitAction,
11
11
  type FormResetOptions,
12
- type VisitOptions,
12
+ type FormState,
13
+ type FormSubmitOptions,
13
14
  type VoidRouter,
14
15
  } from 'void/pages-client';
15
16
  import type { ActionUrl, ResolveActionBody, ActionFormOptions } from 'void/routes';
16
17
 
17
- type FormSubmitOptions = Omit<VisitOptions, 'method' | 'data'> & {
18
- data?: Record<string, unknown>;
19
- };
20
- type UntypedForm = {
21
- readonly data: Record<string, unknown>;
22
- readonly errors: Record<string, string>;
23
- readonly error: VoidActionError | null;
24
- readonly pending: boolean;
25
- readonly hasChanges: boolean;
26
- readonly wasSuccessful: boolean;
27
- readonly recentlySuccessful: boolean;
28
- post(opts?: FormSubmitOptions): Promise<void>;
29
- put(opts?: FormSubmitOptions): Promise<void>;
30
- patch(opts?: FormSubmitOptions): Promise<void>;
31
- delete(opts?: FormSubmitOptions): Promise<void>;
32
- reset(options: FormResetOptions, ...fields: ReadonlyArray<string>): void;
33
- reset(...fields: ReadonlyArray<string>): void;
34
- setError(field: string, message: string): void;
35
- clearErrors(...fields: ReadonlyArray<string>): void;
36
- clearError(): void;
37
- };
18
+ type FormReturn<TData> = FormState<TData, 'in-place'> &
19
+ Readonly<{
20
+ post(opts?: FormSubmitOptions): Promise<void>;
21
+ put(opts?: FormSubmitOptions): Promise<void>;
22
+ patch(opts?: FormSubmitOptions): Promise<void>;
23
+ delete(opts?: FormSubmitOptions): Promise<void>;
24
+ }>;
38
25
 
39
26
  // Typed overload — full inference from PageActionMap (requires codegen)
40
27
  export function useForm<U extends ActionUrl & string>(
41
28
  url: U,
42
29
  defaults: ResolveActionBody<U>,
43
30
  options?: ActionFormOptions<U>,
44
- ): {
45
- readonly data: ResolveActionBody<U>;
46
- readonly errors: Partial<Record<keyof ResolveActionBody<U> & string, string>>;
47
- readonly error: VoidActionError | null;
48
- readonly pending: boolean;
49
- readonly hasChanges: boolean;
50
- readonly wasSuccessful: boolean;
51
- readonly recentlySuccessful: boolean;
52
- post(opts?: FormSubmitOptions): Promise<void>;
53
- put(opts?: FormSubmitOptions): Promise<void>;
54
- patch(opts?: FormSubmitOptions): Promise<void>;
55
- delete(opts?: FormSubmitOptions): Promise<void>;
56
- reset(
57
- options: FormResetOptions,
58
- ...fields: ReadonlyArray<keyof ResolveActionBody<U> & string>
59
- ): void;
60
- reset(...fields: ReadonlyArray<keyof ResolveActionBody<U> & string>): void;
61
- setError(field: keyof ResolveActionBody<U> & string, message: string): void;
62
- clearErrors(...fields: ReadonlyArray<keyof ResolveActionBody<U> & string>): void;
63
- clearError(): void;
64
- };
31
+ ): FormReturn<ResolveActionBody<U>>;
65
32
 
66
33
  export function useForm(
67
34
  url: string,
68
35
  defaults: Record<string, unknown>,
69
36
  options?: { params?: Record<string, string>; resetOnSuccess?: boolean },
70
- ): UntypedForm {
37
+ ): FormReturn<Record<string, unknown>> {
71
38
  const router = getContext<VoidRouter>('__void_router');
72
39
 
73
40
  const initialDefaults = cloneFormValue(defaults);
@@ -95,6 +62,26 @@ export function useForm(
95
62
  });
96
63
  });
97
64
 
65
+ // An input handler may validate its new value before the change effect runs.
66
+ function snapshotField(field: string) {
67
+ untrack(() => {
68
+ dataSnapshot = { ...dataSnapshot, [field]: cloneFormValue(data[field]) };
69
+ });
70
+ }
71
+
72
+ // Handlers may also write `form.errors.field` directly, which needs the same snapshot.
73
+ // The view is derived because setError and clearErrors replace `errors`.
74
+ const errorsView = $derived(
75
+ new Proxy(errors, {
76
+ set(target, field, message) {
77
+ if (typeof field === 'string') {
78
+ snapshotField(field);
79
+ }
80
+ return Reflect.set(target, field, message);
81
+ },
82
+ }),
83
+ );
84
+
98
85
  const hasChanges = $derived(
99
86
  Object.keys(defaults_).some((key) => !isEqualFormValue(data[key], defaults_[key])),
100
87
  );
@@ -123,8 +110,7 @@ export function useForm(
123
110
  }
124
111
 
125
112
  function setError(field: string, message: string) {
126
- // An input handler may validate its new value before the change effect runs.
127
- dataSnapshot = { ...dataSnapshot, [field]: cloneFormValue(data[field]) };
113
+ snapshotField(field);
128
114
  errors = { ...errors, [field]: message };
129
115
  }
130
116
 
@@ -189,7 +175,7 @@ export function useForm(
189
175
  return data;
190
176
  },
191
177
  get errors() {
192
- return errors;
178
+ return errorsView;
193
179
  },
194
180
  get error() {
195
181
  return error;
@@ -1,29 +1,19 @@
1
- import { isEqualFormValue, submitIslandAction, type VoidActionError } from 'void/pages-client';
1
+ import {
2
+ isEqualFormValue,
3
+ submitIslandAction,
4
+ type IslandFormState,
5
+ type VoidActionError,
6
+ } from 'void/pages-client';
2
7
 
3
- type UntypedIslandForm = {
4
- readonly data: Record<string, unknown>;
5
- readonly errors: Record<string, string>;
6
- readonly error: VoidActionError | null;
7
- readonly pending: boolean;
8
- readonly hasChanges: boolean;
9
- readonly wasSuccessful: boolean;
10
- readonly recentlySuccessful: boolean;
11
- submit(url: string, method: string): Promise<void>;
12
- post(url: string): Promise<void>;
13
- put(url: string): Promise<void>;
14
- patch(url: string): Promise<void>;
15
- delete(url: string): Promise<void>;
16
- reset(...fields: ReadonlyArray<string>): void;
17
- clearErrors(...fields: ReadonlyArray<string>): void;
18
- clearError(): void;
19
- };
8
+ export type IslandFormReturn<T extends Record<string, unknown>> = IslandFormState<T, 'in-place'> &
9
+ Readonly<{ submit(url: string, method: string): Promise<void> }>;
20
10
 
21
11
  /**
22
12
  * Form composable for island pages. Uses fetch + page reload instead of the
23
13
  * Void Router (which is not available in island mode).
24
14
  */
25
- export function useIslandForm(defaults: Record<string, unknown>): UntypedIslandForm {
26
- let data: Record<string, unknown> = $state({ ...defaults });
15
+ export function useIslandForm<T extends Record<string, unknown>>(defaults: T): IslandFormReturn<T> {
16
+ let data: T = $state({ ...defaults });
27
17
  let errors: Record<string, string> = $state({});
28
18
  let error: VoidActionError | null = $state(null);
29
19
  let pending = $state(false);
@@ -37,7 +27,7 @@ export function useIslandForm(defaults: Record<string, unknown>): UntypedIslandF
37
27
  Object.keys(defaults_).some((key) => !isEqualFormValue(data[key], defaults_[key])),
38
28
  );
39
29
 
40
- function reset(...fields: ReadonlyArray<string>) {
30
+ function reset(...fields: ReadonlyArray<keyof T & string>) {
41
31
  if (fields.length === 0) {
42
32
  data = { ...defaults_ };
43
33
  } else {