Skip to content

refactor(pages): share the Link contract across framework adapters - #672

Merged
cpojer merged 2 commits into
voidzero-dev:mainfrom
yangshun:refactor/shared-link-contract
Nov 3, 2026
Merged

cpojer merged 2 commits into
voidzero-dev:mainfrom
yangshun:refactor/shared-link-contract

Conversation

@yangshun

@yangshun yangshun commented Nov 2, 2026 •

Copy link
Copy Markdown
Collaborator

What this fixes

  • Vue could prefetch the same page twice. Moving the pointer out of a hover-prefetch Link and back in before the delay fired started a second timer without clearing the first, so both fired. Vue now clears the pending timer first, like the other adapters.
  • Svelte rejected readonly prefetch arrays. <Link prefetch={['hover', 'visible'] as const}> was a type error in Svelte only, because its props took a mutable Array. Svelte now accepts ReadonlyArray like React, Vue and Solid.
  • The four Link copies can't drift apart again. The props, strategy and method types, and the prefetch, click and visibility logic each live in one place. Vue's runtime prop declarations are now type-checked against the shared BaseLinkProps, so a mismatch fails vp run check.

Summary

Each framework adapter had its own copy of the Link props, the prefetch strategy and method unions, and roughly 150 lines of prefetch, click and visibility logic. This PR moves them into void/pages-client, next to the shared <Form> helpers from form-presentation.ts:

  • Types: BaseLinkProps, LinkPrefetchStrategy, LinkNavigateEvent, ResolvedLink. They reuse the existing NavigationMethod and CacheForInput types instead of new copies.
  • Helpers:
    • resolveLink: works out the method and destination, and throws the two "GET only" errors
    • linkPrefetchStrategies
    • prefetchLink
    • handleLinkClick: the click checks, then onNavigate, then visit
    • observeLinkVisible

Each adapter keeps only what differs by framework: reactivity, event-name casing, calling the user's own handlers (Vue handler arrays, Solid [fn, data] pairs), merging style, and rendering. The four adapter Link files are 466 lines shorter in total.

⚠️ Drift between the copies

The four copies had already diverged. This PR picks one behavior for each difference:

Drift Before After
prefetch array type Svelte accepted only a mutable Array, so prefetch={['hover', 'visible'] as const} was a type error in Svelte only Every adapter accepts ReadonlyArray
Re-hover before the delay fires Vue started a new hover timer without clearing the pending one, so a quick leave and re-enter could prefetch twice Vue clears the pending timer first, like React, Svelte and Solid
Prefetch options React and Solid declared their own PrefetchOptions without method; Vue and Svelte used the engine type and passed method: 'GET' Every adapter uses the engine type and omits method. It was always GET, which is the router's default
prefetch left undefined Only Solid explicitly treated it as off; the others relied on a default value resolveLink and linkPrefetchStrategies treat undefined as off

Everything else behaves as before: the same error messages, modified-click, download and cross-origin handling, onNavigate cancellation, GET data merging, and hover/click/mount/visible prefetching.

Not changed here: LinkProps is still exported publicly only from @void/solid. Exporting it from every adapter is a public API decision for a follow-up.

Test plan

  • vp run check: all 33 projects pass, including Vue's runtime-props-vs-BaseLinkProps check
  • vp lint and vp fmt
  • New packages/void/test/unit/pages-link.test.ts: 22 tests for the shared helpers
  • Adapter browser contract suites (React, Vue, Svelte, Solid), Link unit tests and route href type checks: 219 passed after rebuilding the playgrounds with vp run build:all
  • Full vp test run: 15,547 passed. The one failure is plugin-cloudflare-v2.test.ts > "keeps Container builds deferred…", which hit the 5-second limit at 5.5 seconds under full-suite load and passes on its own in about 1.5 seconds. It doesn't touch Link.

Note for local testing: the adapter integration suites only build their playground when dist is missing. A stale playground/*-pages/dist makes them test old code, so run vp run build:all first.

🤖 Generated with Claude Code

Each adapter declared its own copy of the Link props, prefetch strategies
and method union, and repeated the prefetch, click and visibility logic.
Move them into `void/pages-client` next to the shared Form helpers:

- `BaseLinkProps`, `LinkPrefetchStrategy`, `LinkNavigateEvent`, `ResolvedLink`
- `resolveLink`, `linkPrefetchStrategies`, `prefetchLink`,
  `handleLinkClick`, `observeLinkVisible`

Adapters keep reactivity, event naming, user handler composition, style
merging and rendering. Vue's runtime prop check now compares against the
shared props.

This also removes drift between the copies: Svelte's `prefetch` accepts
readonly arrays like the other adapters, and Vue clears a pending hover
timer before starting a new one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@yangshun
yangshun marked this pull request as ready for review November 2, 2026 12:03
@yangshun
yangshun marked this pull request as draft November 2, 2026 12:48
@yangshun
yangshun marked this pull request as ready for review November 2, 2026 13:10
The runtime prop check now compares against the shared `BaseLinkProps`,
not the removed `VoidLinkProps`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@cpojer cpojer left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice cleanup. Definitely best to keep the client logic abstract and shared across all the frameworks.

@cpojer
cpojer merged commit 6f41724 into voidzero-dev:main Nov 3, 2026
2 checks passed
@yangshun
yangshun deleted the refactor/shared-link-contract branch November 3, 2026 00:27
cpojer pushed a commit that referenced this pull request Nov 3, 2026
## Summary

Moves the form contracts that every adapter promises into core
(`packages/void/src/pages/form-presentation.ts`, next to
`FormModel`/`FormMethod`). Only contracts that must not drift between
adapters are shared. Framework idioms stay in the adapters.

**Shared in core** (`FormState`, `IslandFormState` and
`FormSubmitOptions` are exported from `void/pages-client`)

| Type | Members | Used by |
| --- | --- | --- |
| `FormStatus` | `error`, `pending`, `hasChanges`, `wasSuccessful`,
`recentlySuccessful` | `useForm` and `useIslandForm` in all four
adapters; `FormModel` takes `pending` and `error` from it |
| `FormState<TData, TEditing>` | `FormStatus` + `data`, `errors`
(field-keyed), `reset` (with the `{ initial }` overload that core's
`parseFormResetArgs` parses), `setError`, `clearErrors`, `clearError` |
`useForm`, all four adapters |
| `IslandFormState<TData, TEditing>` | `FormStatus` + `data`, `errors`
(keyed by any name the action returns), `reset` (field-typed),
`clearErrors`, `clearError`, `post/put/patch/delete(url)` |
`useIslandForm`, all four adapters |
| `FormSubmitOptions` | `Omit<VisitOptions, 'method' \| 'data'> & {
data? }`, previously byte-identical in Vue, Svelte and Solid | `useForm`
in Vue, Svelte and Solid |

Core defines the `data` and `errors` types once, including the rule for
error keys. Each adapter must pick an editing mode, and there is no
default:
- `'in-place'` for Vue and Svelte: apps edit `data` and `errors`
directly.
- `'setters'` for React and Solid: `data` and `errors` are `Readonly`
and change through `setData`, `setError` and `clearErrors`.

**Kept in adapters** (agreed API decisions; no behavior change)
- React's `post/put/patch/delete(formData: FormData)` stay native `<form
action>` callbacks. Vue, Svelte and Solid keep `post(opts?:
FormSubmitOptions)`.
- `setData` stays in React and Solid only. Vue and Svelte bind to
`form.data` directly.
- Island `submit(url, method)` stays in Vue and Svelte only, and island
`setData` stays in React and Solid only.

Each adapter has a local `FormReturn<TData>` alias. The typed overload
and the implementation signature both use it, so they can no longer
drift. It replaces each adapter's `UntypedForm` and its duplicated typed
return object. Vue's `useIslandForm` now uses the same overload pattern.
Its returned object is checked against the island contract; before, it
was cast to it. Svelte's `useIslandForm` is now generic like the other
three adapters, so `form.data` and `reset()` field names are typed from
the defaults instead of `Record<string, unknown>`.

## TypeScript compatibility (release note)

Form types now follow each framework's idiom. Runtime behavior is
unchanged.

- **Top-level form fields are read-only in every adapter**, for both
`useForm()` and `useIslandForm()`: `data`, `errors`, `error`, `pending`,
`hasChanges`, `wasSuccessful` and `recentlySuccessful`. Writing
`form.pending = true` or replacing `form.data = { … }` no longer
compiles, because a replaced `data` or `errors` object is no longer the
one the form submits and updates. Form methods are read-only too. This
is new for `useForm()` in `@void/react`, `@void/vue` and `@void/solid`;
Svelte and island forms already had read-only fields.
- **React and Solid: the contents of `data` and `errors` are read-only**
(both hooks). `form.data.email = …` and `form.errors.email = …` no
longer compile; use `setData()`, `setError()` and `clearErrors()`. In
React these writes changed the submitted data without re-rendering.
Solid stores ignore them. In Solid, a write into `form.errors` also
changed the server errors that every form shares.
- **Vue and Svelte: `data` and `errors` stay editable in place**
(`v-model`, `bind:value`, `form.errors.title = …`). No setters are
required.
- **Island `errors` values are `string | undefined`** in every adapter,
matching `useForm()`. `form.errors.title.length` now needs a guard;
before, it compiled and then crashed when the field had no error.
- **Svelte `useIslandForm()` infers its data type from `defaults`**,
like the other adapters. These now fail to compile: reading a field that
is not in `defaults`, passing an unknown field to `reset()`, and
assigning a value of a different type. A `null` default needs its full
type, for example `{ photo: null as File | null }`, as React, Vue and
Solid already require.

## Risk

- Type-only change, apart from Vue's `useIslandForm` dropping an `as`
cast. The emitted JavaScript is unchanged.
- TypeScript ignores `readonly` when checking assignability, so `<Form
form={form}>` and other consumers are unaffected. The typecheck tests
now assert this.
- No user docs change. `void/pages-client` is adapter-facing, and the
documented form API is unchanged.

## Verification

- `vp run check:sdk`: 12/12 packages pass. Each adapter's
`form-errors.typecheck.ts` and `island-form.typecheck.ts` pin:
  - read-only top-level fields and methods;
- that `data` and `errors` are editable in Vue and Svelte and read-only
in React and Solid;
  - that `useForm()` is accepted by `<Form form>` (`FormModel`);
  - which adapters have `setData` or `submit()`;
  - each adapter's submit signature;
  - that island errors may be `undefined` and accept any key;
  - typed island `data` and `reset` fields.
- The four `*-pages` playgrounds typecheck against the rebuilt adapters.
- Focused form unit tests: 1295 tests pass.
- Earlier on this branch: `vp run test` passed 515 tests with 1 skipped.
The 27 platform-unit failures came from a missing generated
`platform/packages/dashboard/.void/tsconfig.json` in a fresh worktree;
after generating it, all 27 pass. `vp test run` passed 759 of 764 files
with 1 skipped. Four failed only under full-suite load and all pass when
rerun on their own: the astro dev server start timeout, and timeouts in
`db-template`, `deploy-direct-r2` and `zero-trust-service`.
- `vp lint` and `vp fmt --check` are clean on the changed files.
- After merging `main` (including #672 and #673, which share Link and
build-plugin code through the same `void/pages-client` entry), there
were no conflicts. `vp run check:sdk` passes 12/12, the four `*-pages`
playgrounds typecheck, and `vp test run form island` passes 357 files
(7349 tests, 22 skipped).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants