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
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