Skip to content

Commit e3c8a15

Browse files
authored
fix(solid): keep useParams() current after client navigation (#709)
## Problem Fixes #708. In Solid pages, `useParams()` keeps returning the first page's params after a client-side navigation. On `/posts/[slug]`, clicking a `<Link>` from `/posts/a` to `/posts/b` updates the page props, but `const params = useParams(); params.slug` still reads `"a"`. `useRouter().params` is correct. The generated client passes the params with `get value() { return routeParams(); }`, but Solid's context provider reads `value` once, inside `untrack`. The context keeps the first object, even for pages created after a navigation. ## What changed - `packages/solid/src/plugin-pages.ts`: params are now a stable `createMutable` object updated in place, the same way shared data already is. The `replaceShared` helper is now `replaceInPlace(target, next)` and is used for both. This matches the Svelte fix in #610. - The `routeParams` signal stays for the router facade and the `sameParams` check, so `useRouter().params` is unchanged. ## Tests - New `packages/solid/test/integration/use-params-navigation.test.ts`: runs the real generated client in Chromium with a page and a layout that call `useParams()`, clicks a `<Link>` from `a` to `b`, and checks that both show `b` without a document reload. A small Vite middleware stands in for the Worker's page responses. It fails without the fix. ## To check - `useParams()` now always returns the same object for the life of the app, as `useShared()` already does.
1 parent 229458a commit e3c8a15

2 files changed

Lines changed: 121 additions & 10 deletions

File tree

‎packages/solid/src/plugin-pages.ts‎

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -153,15 +153,17 @@ const [page, setPage] = createSignal({ component: null });
153153
const [pageProps, setPageProps] = createSignal({});
154154
const [layouts, setLayouts] = createSignal([]);
155155
const shared = createMutable({});
156+
const params = createMutable({});
156157
const [errors, setErrors] = createSignal({});
157158
const [routeUrl, setRouteUrl] = createSignal(window.location.pathname + window.location.search + window.location.hash);
158159
const [routeParams, setRouteParams] = createSignal({});
159160
const [navigation, setNavigation] = createSignal(idleNavigationState());
160161
161-
// Keep the context object stable for layouts that call useShared() once.
162-
function replaceShared(next) {
163-
for (const key of Object.keys(shared)) delete shared[key];
164-
Object.assign(shared, next || {});
162+
// Pages and layouts keep the objects returned by useShared() and useParams()
163+
// across navigations, so update them in place.
164+
function replaceInPlace(target, next) {
165+
for (const key of Object.keys(target)) delete target[key];
166+
Object.assign(target, next || {});
165167
}
166168
167169
function sameParams(a, b) {
@@ -177,18 +179,19 @@ const { router, initialPageData, initialComponent, initialLayouts, start, commit
177179
rejectDeferred(ref, error) { return { value: null, loading: false, error: new Error(error) }; },
178180
isLoading: (ref) => ref.loading,
179181
applyUpdate(pageData, comp, layoutComps, options) {
180-
const params = pageData.params || {};
182+
const nextParams = pageData.params || {};
181183
// Back/forward preserves state, but another route or another record
182184
// still needs a new page so useForm() targets that route and record.
183185
const current = page();
184-
const keep = options?.preserveState && current.id === pageData.component && current.component === comp && sameParams(routeParams(), params);
186+
const keep = options?.preserveState && current.id === pageData.component && current.component === comp && sameParams(routeParams(), nextParams);
185187
batch(() => {
186188
if (!keep) setPage({ component: comp, id: pageData.component });
187189
setPageProps(pageData.props);
188190
setLayouts(layoutComps);
189-
replaceShared(pageData.shared);
191+
replaceInPlace(shared, pageData.shared);
190192
setErrors(pageData.errors || {});
191-
setRouteParams(params);
193+
replaceInPlace(params, nextParams);
194+
setRouteParams(nextParams);
192195
});
193196
},
194197
onDeferredUpdate(props) {
@@ -218,8 +221,9 @@ batch(() => {
218221
setPage({ component: initialComponent, id: initialPageData.component });
219222
setPageProps(initialPageData.props);
220223
setLayouts(initialLayouts);
221-
replaceShared(initialPageData.shared);
224+
replaceInPlace(shared, initialPageData.shared);
222225
setErrors(initialPageData.errors || {});
226+
replaceInPlace(params, initialPageData.params);
223227
setRouteParams(initialPageData.params || {});
224228
});
225229
@@ -254,7 +258,7 @@ function App() {
254258
value: reactiveRouter,
255259
get children() {
256260
return createComponent(ParamsContext.Provider, {
257-
get value() { return routeParams(); },
261+
value: params,
258262
get children() {
259263
return createComponent(NavigationContext.Provider, {
260264
value: navigation,
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
2+
import { join } from 'node:path';
3+
import { chromium } from 'playwright';
4+
import { createServer } from 'vite';
5+
import solid from 'vite-plugin-solid';
6+
import { expect, it } from 'vite-plus/test';
7+
import { pagesPlugin } from '../../src/plugin-pages.ts';
8+
9+
const PAGE = `import { Link, useParams } from "@void/solid";
10+
export default function Post(props) {
11+
const params = useParams();
12+
return <main>
13+
<h1 id="title">{props.title}</h1>
14+
<p id="slug">{params.slug}</p>
15+
<Link href="/posts/b">Post b</Link>
16+
</main>;
17+
}`;
18+
19+
const LAYOUT = `import { useParams } from "@void/solid";
20+
export default function Layout(props) {
21+
const params = useParams();
22+
return <div><p id="layout-slug">{params.slug}</p>{props.children}</div>;
23+
}`;
24+
25+
function pageData(slug: string) {
26+
return {
27+
component: 'posts/[slug]',
28+
props: { title: `Post ${slug}` },
29+
params: { slug },
30+
shared: {},
31+
url: `/posts/${slug}`,
32+
};
33+
}
34+
35+
// The document a Void Worker sends for a client-rendered page. It boots the real
36+
// generated pages client, which then fetches pageData() as JSON on a Link navigation.
37+
function clientDocument(slug: string): string {
38+
return `<!doctype html>
39+
<script id="__VOID_PAGE_DATA__" type="application/json">${JSON.stringify(pageData(slug))}</script>
40+
<div id="app" data-void-ssr="false"></div>
41+
<script type="module" src="/@id/__x00__virtual:void-pages-client"></script>`;
42+
}
43+
44+
it('useParams() follows navigation between two params of the same page', async () => {
45+
const root = mkdtempSync(join(import.meta.dirname, '.tmp-use-params-'));
46+
const pages = join(root, 'pages');
47+
mkdirSync(join(pages, 'posts'), { recursive: true });
48+
writeFileSync(join(pages, 'layout.tsx'), LAYOUT);
49+
writeFileSync(join(pages, 'posts', '[slug].tsx'), PAGE);
50+
51+
const browser = await chromium.launch();
52+
const server = await createServer({
53+
root,
54+
configFile: false,
55+
cacheDir: join(root, '.vite'),
56+
logLevel: 'silent',
57+
appType: 'custom',
58+
server: { port: 0, host: '127.0.0.1' },
59+
resolve: { alias: { '@void/solid': join(import.meta.dirname, '../../src/runtime/index.ts') } },
60+
plugins: [
61+
solid(),
62+
pagesPlugin(pages, { root }),
63+
{
64+
name: 'test:void-page-responses',
65+
configureServer(viteServer) {
66+
viteServer.middlewares.use((req, res, next) => {
67+
const slug = /^\/posts\/(\w+)$/.exec(req.url ?? '')?.[1];
68+
if (!slug) {
69+
next();
70+
return;
71+
}
72+
if (req.headers['x-voidpages']) {
73+
res.setHeader('Content-Type', 'application/json');
74+
res.setHeader('X-VoidPages', 'true');
75+
res.end(JSON.stringify(pageData(slug)));
76+
return;
77+
}
78+
res.setHeader('Content-Type', 'text/html');
79+
res.end(clientDocument(slug));
80+
});
81+
},
82+
},
83+
],
84+
});
85+
try {
86+
await server.listen();
87+
const page = await browser.newPage();
88+
await page.goto(`${server.resolvedUrls!.local[0]}posts/a`);
89+
await page.waitForSelector('#app[data-hydrated="true"]');
90+
expect(await page.textContent('#slug')).toBe('a');
91+
expect(await page.textContent('#layout-slug')).toBe('a');
92+
93+
// A full reload would also render "b", so check that the document survives.
94+
await page.evaluate(() => {
95+
document.documentElement.dataset.sameDocument = 'true';
96+
});
97+
await page.click('a[href="/posts/b"]');
98+
await page.waitForFunction(() => document.getElementById('title')?.textContent === 'Post b');
99+
expect(await page.textContent('#slug')).toBe('b');
100+
expect(await page.textContent('#layout-slug')).toBe('b');
101+
expect(await page.getAttribute('html', 'data-same-document')).toBe('true');
102+
} finally {
103+
await server.close();
104+
await browser.close();
105+
rmSync(root, { recursive: true, force: true });
106+
}
107+
});

0 commit comments

Comments
 (0)