Skip to content

fix(pages): compare a Link's query when marking it current - #719

Merged
cpojer merged 1 commit into
voidzero-dev:mainfrom
yangshun:fix/link-active-query
Nov 5, 2026
Merged

cpojer merged 1 commit into
voidzero-dev:mainfrom
yangshun:fix/link-active-query

Conversation

@yangshun

@yangshun yangshun commented Nov 5, 2026

Copy link
Copy Markdown
Collaborator

Fixes #713.

What changed

Link added aria-current="page" and data-void-active to any link whose path matched the current page, ignoring the query. On /posts?page=2, both the Previous (?page=1) and Next (?page=3) links were announced as "current page".

linkActiveAttributes() now uses the subset rule agreed in the issue, the same default as TanStack Router and Angular:

  • A link without a query still ignores the query, so /posts stays current on /posts?page=2.
  • A link with a query is current only when each of its params has the same value in the current URL. The current URL can have extra params in any order.
  • GET data counts as part of the query, since it's merged into the href.
  • The hash is still ignored.
  • activeMatch="prefix" still compares only the path on nested pages.
Current URL Link Before After
/posts?page=2 /posts?page=1 current not current
/posts?page=2 /posts?page=2 current current
/posts?page=2 /posts current current
/issues?status=open&page=2 /issues?status=open current current
/issues?status=open /issues?status=closed current not current
/search /search with data={{ q: 'void' }} current not current
/docs#install #usage current current
/reports/42 /reports?range=30d with activeMatch="prefix" data-void-active data-void-active

For reviewers

  • Same path, different query, with prefix: a prefix link on its own path with a different query is now not active at all (/reports?range=30d on /reports?range=7d). Only nested pages compare the path alone. Otherwise, filter tabs using prefix would all be data-void-active at once.
  • Shared helper: all four adapters (React, Vue, Svelte, Solid) call the same helper, so the fix lives in packages/void/src/pages/link.ts.
  • Tests:
    • Unit cases in pages-link.test.ts cover the table above, plus query-only hrefs, repeated params, encoded values (+ and %20) and empty values. The new cases fail on the old code.
    • The shared browser contract now checks that the playground's ?view=compact link is not current on /link-behavior and becomes current after a client navigation to it. That runs in dev and preview for every adapter, through SSR, hydration and a query-only navigation.
  • Docs: the active links section of docs/guide/pages-routing/overview.md covers the query rule, the prefix behavior, and setting the attributes yourself on default-value links such as an "All" tab.

Verification

  • vp test run for pages-link.test.ts and the four adapter browser suites (react-pages-client, vue-pages-client, svelte-pages-client, solid-pages-playground): 245 tests passed.
  • vp run check and lint are clean.
  • Full vp test run: 775 of 777 files passed (1 skipped), plus one failing file. Its 2 failures are in websockets-playground.test.ts, where /chat and /notifications never hydrate. They fail the same way on main without this change, so that's being looked at separately.

🤖 Generated with Claude Code

A Link to the current path was always marked with aria-current="page"
and data-void-active, even when its href changed only the query. On
/posts?page=2, Previous and Next pagination links were both announced
as the current page.

A link with a query is now current only when each of its params has
the same value in the current URL. The current URL can have extra
params in any order, so a filter tab stays current while paging. GET
data counts as part of the query, links without a query still ignore
it, the hash is still ignored, and activeMatch="prefix" still compares
only the path on nested pages.

Fixes voidzero-dev#713

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.

Thanks for the fix!

@cpojer
cpojer merged commit 2680b99 into voidzero-dev:main Nov 5, 2026
18 checks passed
cpojer pushed a commit that referenced this pull request Nov 5, 2026
## What changed

Locally, React Pages apps in this repo could stop hydrating after `vp
run build`. The browser threw:

```
SyntaxError: The requested module '/node_modules/.vite/deps/void_pages-client.js?v=…'
does not provide an export named 'isVoidActionError'
```

The client entry never loaded, so `#app` was never marked hydrated.
`websockets-playground.test.ts` timed out on `/chat` and
`/notifications` (noted in #719). CI passed because it starts with no
Vite cache.

### Cause

In dev, the React adapter listed `void/pages-client` and
`void/pages-prefetch` in the client `optimizeDeps.include` (#620). It
listed `void/live/client` in the worker include. Here `void` is a linked
workspace package. Vite serves a linked package as source unless it is
listed in `include`, and a listed one is pre-bundled into
`node_modules/.vite`. That cache is keyed by the lockfile, config and
`NODE_ENV`, not by the linked package's files, so rebuilding Void left
the old copy in place.

#715 added `isVoidActionError` to `void/pages-client` and re-exported it
from `@void/react`. Any cache written before #715 then broke the client.

### Fix

`installedDependencies()` keeps a Void entry in `include` only when it
resolves into `node_modules`:

| `void` install | Before | After |
| --- | --- | --- |
| From the registry (`node_modules`) | pre-bundled | pre-bundled (keeps
#620's cold-start fix) |
| Linked workspace package | pre-bundled, stale after a rebuild | served
as source, always the current build |

This only changes dev behavior for linked installs. Apps that install
`void` from npm see no difference.

## For reviewers

- **Served as source:** in a linked install, these entries pull in only
`@void/edge`, which is linked too and has no imports. Serving them as
source therefore can't trigger a late dependency discovery or a full
reload.
- **`void/live/client`:** the worker include had the same stale-copy
hazard, so the same rule applies there.
- **Resolution:** `import.meta.resolve` from `@void/react`. `void` is a
peer dependency, so this finds the same `void` the app uses.

## Verification

- Replayed the failure: built `void` at #715's parent and ran the
websockets suite to fill the cache. Then I rebuilt on this branch and
ran it again. Before this change: 2 failed. After: 3/3 passed on two
runs.
- `packages/react/test/unit/plugin-pages.test.ts`: 34 passed. A new unit
test covers installed paths (pnpm, npm, Windows) and a linked path. The
dev-config test now checks that this workspace's linked `void` is not
pre-bundled.
- `vp run check`, lint and format are clean.
- Full `vp test run`: 774 of 777 files passed (1 skipped). The other 2
files were the Vue and Svelte `*-pages-client` preview suites. They
reuse an existing playground `dist`, and this worktree's predated their
newest fixtures. After deleting those `dist` folders, both passed (94
tests).

To clear a stale cache without this change:

```sh
rm -rf playground/<app>/node_modules/.vite
```

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

Link marks links to the same page with a different query as aria-current="page"

2 participants