@void/svelte 1.4.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,17 +1,9 @@
1
1
  import { svelte } from "@sveltejs/vite-plugin-svelte";
2
+ import { PrefetchConfig } from "void/pages";
2
3
  import { Plugin } from "vite";
3
- //#region src/plugin-pages.d.ts
4
- type PrefetchConfig = {
5
- hoverDelay?: number;
6
- cacheFor?: number | string | [string, string];
7
- };
8
- //#endregion
9
- //#region src/plugin.d.ts
10
4
  export type VoidSvelteOptions = Readonly<{
11
5
  svelte?: Parameters<typeof svelte>[0];
12
6
  viewTransitions?: boolean;
13
7
  prefetch?: PrefetchConfig;
14
8
  }>;
15
- export declare function voidSvelte(options?: VoidSvelteOptions): Array<Plugin>;
16
- //#endregion
17
- export {};
9
+ export declare function voidSvelte(options?: VoidSvelteOptions): Array<Plugin>;
package/dist/plugin.mjs CHANGED
@@ -1,11 +1,10 @@
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";
8
- //#region ../../node_modules/.pnpm/pathslash@0.1.0/node_modules/pathslash/dist/index.mjs
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";
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";
11
10
  /**
@@ -43,13 +42,24 @@ const posix = { ...nodePath.posix };
43
42
  posix.posix = win32.posix = posix;
44
43
  posix.win32 = win32.win32 = win32;
45
44
  const { basename, delimiter, dirname, extname, format, isAbsolute, join, matchesGlob, normalize, parse, relative, resolve, sep, toNamespacedPath } = isWindows ? win32 : posix;
46
- //#endregion
47
- //#region src/plugin-islands.ts
45
+ // src/plugin-islands.ts
48
46
  const COMPONENT_EXTENSIONS = [
49
47
  ".svelte",
50
48
  ".ts",
51
49
  ".js"
52
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
+ }
53
63
  /**
54
64
  * Prefix for virtual island wrapper modules. Must NOT use the `\0` prefix
55
65
  * because `@sveltejs/vite-plugin-svelte` skips `\0`-prefixed modules in
@@ -59,27 +69,10 @@ const WRAPPER_PREFIX = "/@void-island-wrap/";
59
69
  function islandsPlugin(pagesDir, options) {
60
70
  let pageScan = null;
61
71
  const root = options?.root ?? dirname(pagesDir);
62
- const islandManifest = /* @__PURE__ */ new Map();
63
- const islandCssFiles = /* @__PURE__ */ new Set();
72
+ const islandManifest = createIslandManifest(root, COMPONENT_EXTENSIONS);
73
+ let cssFiles = /* @__PURE__ */ new Set();
64
74
  let isDev = false;
65
75
  let resolvedConfig = null;
66
- function registerIsland(spec, importerDir, hydrate) {
67
- const absPath = resolveSpecifier(spec, importerDir, COMPONENT_EXTENSIONS);
68
- if (!absPath) return {
69
- absPath,
70
- islandId: spec
71
- };
72
- const islandId = normalizePath(relative(root, absPath)).replace(/\.[^.]+$/, "");
73
- islandManifest.set(islandId, {
74
- componentPath: absPath,
75
- islandId,
76
- hydrate
77
- });
78
- return {
79
- absPath,
80
- islandId
81
- };
82
- }
83
76
  return [
84
77
  {
85
78
  name: "void-svelte:islands",
@@ -99,33 +92,8 @@ function islandsPlugin(pagesDir, options) {
99
92
  pagesDir,
100
93
  environment: isDev ? "dev" : "prod"
101
94
  });
102
- const filesToScan = [
103
- ...pageScan.pages.map((page) => ({
104
- filePath: page.componentPath,
105
- island: page.island
106
- })),
107
- ...pageScan.layouts.map((layout) => ({
108
- filePath: layout.filePath,
109
- island: layout.island
110
- })),
111
- ...pageScan.namedLayouts.map((namedLayout) => ({
112
- filePath: namedLayout.filePath,
113
- island: namedLayout.island
114
- }))
115
- ];
116
- for (const entry of filesToScan) {
117
- if (!entry.island) continue;
118
- const filePath = resolve(pagesDir, entry.filePath);
119
- const code = readFileSync(filePath, "utf-8");
120
- const importerDir = dirname(filePath);
121
- for (const islandImport of findIslandImports(code, filePath)) registerIsland(islandImport.specifier, importerDir, islandImport.hydrate);
122
- }
123
- for (const page of pageScan.pages) {
124
- if (!page.island) continue;
125
- if (page.componentPath.endsWith(".svelte")) islandCssFiles.add(normalizePath(resolve(pagesDir, page.componentPath)));
126
- const layoutChain = resolveEffectiveLayoutChain(page, pageScan.layouts, pageScan.namedLayouts);
127
- for (const layout of layoutChain) islandCssFiles.add(normalizePath(resolve(pagesDir, layout.filePath)));
128
- }
95
+ islandManifest.registerPages(pageScan, pagesDir);
96
+ cssFiles = islandCssFiles(pageScan, pagesDir, /\.svelte$/);
129
97
  }
130
98
  },
131
99
  buildEnd() {
@@ -143,14 +111,21 @@ function islandsPlugin(pagesDir, options) {
143
111
  handler(id) {
144
112
  if (id === "\0" + ISLANDS_RENDERER_ID) {
145
113
  if (!pageScan) return "export function renderIslandPage() { throw new Error('No pages found'); }";
146
- const mdPlugin = resolvedConfig?.plugins?.find((plugin) => plugin.name === "void-md");
147
- const mdScriptIds = new Set(mdPlugin?.api?.getClientScripts?.()?.keys() ?? []);
114
+ const mdScriptIds = mdClientScriptIds(resolvedConfig);
148
115
  const needsJS = computePagesNeedingClientJS(pageScan, pagesDir, resolvedConfig);
149
116
  return generateIslandRenderer(pageScan, pagesDir, mdScriptIds, needsJS);
150
117
  }
151
118
  if (id === "\0" + ISLANDS_CLIENT_ENTRY_ID) {
152
119
  if (!pageScan) return "";
153
- 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
+ });
154
129
  }
155
130
  if (id.startsWith(WRAPPER_PREFIX)) return generateWrapperComponent(id);
156
131
  }
@@ -165,7 +140,7 @@ function islandsPlugin(pagesDir, options) {
165
140
  const name = islandImport.importName;
166
141
  const spec = islandImport.specifier;
167
142
  const strategy = islandImport.hydrate;
168
- const { absPath, islandId } = registerIsland(spec, importerDir, strategy);
143
+ const { absPath, islandId } = islandManifest.register(spec, importerDir, strategy);
169
144
  const wrapperUrl = WRAPPER_PREFIX + encodeURIComponent(islandId) + ".svelte?src=" + encodeURIComponent(absPath || spec) + "&island=" + encodeURIComponent(islandId) + "&hydrate=" + encodeURIComponent(strategy);
170
145
  const replacement = `import ${name} from ${JSON.stringify(wrapperUrl)}`;
171
146
  edits.push({
@@ -180,10 +155,7 @@ function islandsPlugin(pagesDir, options) {
180
155
  async config(_, env) {
181
156
  if (!existsSync(pagesDir)) return;
182
157
  if (env.command === "serve") return { optimizeDeps: { include: ["svelte"] } };
183
- if (await hasIslandPages(root, pagesDir)) return { environments: { client: { build: {
184
- manifest: true,
185
- rollupOptions: { input: { "islands-client": ISLANDS_CLIENT_ENTRY_ID } }
186
- } } } };
158
+ return islandsBuildConfig(root, pagesDir);
187
159
  }
188
160
  },
189
161
  {
@@ -298,37 +270,7 @@ export async function renderIslandPage(pageObj, assetTags) {
298
270
  }
299
271
  `;
300
272
  }
301
- function generateIslandClientEntry(manifest, cssFiles, styles, needsRuntime, isDev) {
302
- const entries = [...manifest.values()];
303
- const uniqueIslands = /* @__PURE__ */ new Map();
304
- for (const entry of entries) uniqueIslands.set(entry.islandId, entry.componentPath);
305
- const lines = [];
306
- if (!isDev && styles) for (const id of styles) lines.push(`import ${JSON.stringify(id)};`);
307
- else if (!isDev) for (const file of cssFiles) {
308
- const content = readFileSync(file, "utf-8");
309
- if (/<style\b/.test(content)) lines.push(`import ${JSON.stringify(file + ".css?island-style")};`);
310
- for (const item of findStaticCssImports(content, file)) lines.push(rewriteCssImportForVirtualModule(item.statement, item.specifier, file));
311
- }
312
- if (!needsRuntime && !isDev) return lines.join("\n");
313
- lines.push("import { initIslands } from \"void/pages-client\";");
314
- lines.push("const islands = {");
315
- for (const [id, path] of uniqueIslands) lines.push(` ${JSON.stringify(id)}: () => import(${JSON.stringify(path)}),`);
316
- lines.push("};");
317
- lines.push(`
318
- async function hydrateIsland(el, mod, props, frontmatter) {
319
- const { hydrate } = await import("svelte");
320
- const Component = mod.default || mod;
321
- const context = new Map([["__void_frontmatter", frontmatter]]);
322
- hydrate(Component, { target: el, props, context });
323
- el.setAttribute("data-hydrated", "true");
324
- }
325
-
326
- initIslands(islands, hydrateIsland);
327
- `);
328
- return lines.join("\n");
329
- }
330
- //#endregion
331
- //#region src/plugin-pages.ts
273
+ // src/plugin-pages.ts
332
274
  const CLIENT_ENTRY_ID = "virtual:void-pages-client";
333
275
  const SVELTE_OPTIMIZE_DEPS = {
334
276
  exclude: ["@void/svelte"],
@@ -387,7 +329,7 @@ import { serializePageData } from "void/pages-server";
387
329
 
388
330
  ${generateComponentManifest(scan, pagesDir)}
389
331
 
390
- export async function renderPage(pageObj, assetTags) {
332
+ export async function renderPage(pageObj, assetTags, requestUrl) {
391
333
  const PageComponent = (await components[pageObj.component]()).default;
392
334
  const layoutIds = layoutTree[pageObj.component] || [];
393
335
  const layoutComponents = await Promise.all(
@@ -401,7 +343,7 @@ export async function renderPage(pageObj, assetTags) {
401
343
  pageProps: pageObj.props,
402
344
  shared: pageObj.shared || {},
403
345
  params: pageObj.params || {},
404
- router: createSsrRouter(pageObj.url || "/", pageObj.params || {}),
346
+ router: createSsrRouter(pageObj.url || "/", pageObj.params || {}, requestUrl),
405
347
  routeUrl: pageObj.url || "/",
406
348
  navigation: idleNavigationState(),
407
349
  stores: null,
@@ -426,6 +368,7 @@ export async function renderPage(pageObj, assetTags) {
426
368
  `;
427
369
  }
428
370
  function generateClientEntry(scan, pagesDir, viewTransitions, prefetch, staticPageData) {
371
+ const { defaultStrategy, hoverDelay, cacheFor } = resolvePrefetchConfig(prefetch);
429
372
  return `
430
373
  import { hydrate, mount, tick } from "svelte";
431
374
  import { writable } from "svelte/store";
@@ -469,8 +412,9 @@ const { router, initialPageData, initialComponent, initialLayouts, start, commit
469
412
  staticPageData: ${!!staticPageData?.enabled},
470
413
  staticPageDataFastPath: ${!!staticPageData?.fastPath},
471
414
  viewTransitions: ${!!viewTransitions},
472
- prefetchHoverDelay: ${prefetch?.hoverDelay ?? 75},
473
- prefetchDefaultCacheFor: parseCacheFor(${JSON.stringify(prefetch?.cacheFor ?? "30s")}),
415
+ prefetchHoverDelay: ${hoverDelay},
416
+ prefetchDefaultCacheFor: parseCacheFor(${JSON.stringify(cacheFor)}),
417
+ prefetchDefaultStrategy: ${JSON.stringify(defaultStrategy)},
474
418
  onRouteChange(url) {
475
419
  routeUrl = url;
476
420
  routeStore.set(url);
@@ -528,8 +472,7 @@ commitInitialRender();
528
472
  appEl.setAttribute("data-hydrated", "true");
529
473
  `;
530
474
  }
531
- //#endregion
532
- //#region src/plugin.ts
475
+ // src/plugin.ts
533
476
  function voidSvelte(options) {
534
477
  const root = process.cwd();
535
478
  const pagesDir = resolvePagesDir(root);
@@ -543,5 +486,4 @@ function voidSvelte(options) {
543
486
  ...islandsPlugin(pagesDir, { root })
544
487
  ];
545
488
  }
546
- //#endregion
547
489
  export { voidSvelte };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@void/svelte",
3
- "version": "1.4.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": {
@@ -38,17 +38,17 @@
38
38
  "@sveltejs/vite-plugin-svelte": "^7.3.1"
39
39
  },
40
40
  "devDependencies": {
41
- "@types/node": "^26.6.5",
41
+ "@types/node": "^26.6.6",
42
42
  "pathslash": "^0.1.0",
43
43
  "svelte": "^5.57.2",
44
- "vite": "npm:@voidzero-dev/vite-plus-core@1.3.0",
45
- "vite-plus": "1.3.0",
46
- "void": "1.4.0"
44
+ "vite": "npm:@voidzero-dev/vite-plus-core@1.4.0",
45
+ "vite-plus": "1.4.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.4.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 {