From e7356150d89855ea6682ac4beb00df2be44473fe Mon Sep 17 00:00:00 2001 From: yujiym <1409395+yujiym@users.noreply.github.com> Date: Wed, 4 Nov 2026 23:27:27 +0900 Subject: [PATCH] fix(solid): keep useParams() current after client navigation The generated client passed the params to ParamsContext.Provider through a getter over the routeParams signal. Solid's provider reads its value once, inside untrack, so every useParams() call received the first page's params, even on pages created after a navigation. Update a stable params object in place, the same way shared data is updated, so the object returned by useParams() stays reactive and current. Fixes #708 --- packages/solid/src/plugin-pages.ts | 24 ++-- .../integration/use-params-navigation.test.ts | 107 ++++++++++++++++++ 2 files changed, 121 insertions(+), 10 deletions(-) create mode 100644 packages/solid/test/integration/use-params-navigation.test.ts diff --git a/packages/solid/src/plugin-pages.ts b/packages/solid/src/plugin-pages.ts index c07e942ec..47fb2a3d3 100644 --- a/packages/solid/src/plugin-pages.ts +++ b/packages/solid/src/plugin-pages.ts @@ -153,15 +153,17 @@ const [page, setPage] = createSignal({ component: null }); const [pageProps, setPageProps] = createSignal({}); const [layouts, setLayouts] = createSignal([]); const shared = createMutable({}); +const params = createMutable({}); const [errors, setErrors] = createSignal({}); const [routeUrl, setRouteUrl] = createSignal(window.location.pathname + window.location.search + window.location.hash); const [routeParams, setRouteParams] = createSignal({}); const [navigation, setNavigation] = createSignal(idleNavigationState()); -// Keep the context object stable for layouts that call useShared() once. -function replaceShared(next) { - for (const key of Object.keys(shared)) delete shared[key]; - Object.assign(shared, next || {}); +// Pages and layouts keep the objects returned by useShared() and useParams() +// across navigations, so update them in place. +function replaceInPlace(target, next) { + for (const key of Object.keys(target)) delete target[key]; + Object.assign(target, next || {}); } function sameParams(a, b) { @@ -177,18 +179,19 @@ const { router, initialPageData, initialComponent, initialLayouts, start, commit rejectDeferred(ref, error) { return { value: null, loading: false, error: new Error(error) }; }, isLoading: (ref) => ref.loading, applyUpdate(pageData, comp, layoutComps, options) { - const params = pageData.params || {}; + const nextParams = pageData.params || {}; // Back/forward preserves state, but another route or another record // still needs a new page so useForm() targets that route and record. const current = page(); - const keep = options?.preserveState && current.id === pageData.component && current.component === comp && sameParams(routeParams(), params); + const keep = options?.preserveState && current.id === pageData.component && current.component === comp && sameParams(routeParams(), nextParams); batch(() => { if (!keep) setPage({ component: comp, id: pageData.component }); setPageProps(pageData.props); setLayouts(layoutComps); - replaceShared(pageData.shared); + replaceInPlace(shared, pageData.shared); setErrors(pageData.errors || {}); - setRouteParams(params); + replaceInPlace(params, nextParams); + setRouteParams(nextParams); }); }, onDeferredUpdate(props) { @@ -218,8 +221,9 @@ batch(() => { setPage({ component: initialComponent, id: initialPageData.component }); setPageProps(initialPageData.props); setLayouts(initialLayouts); - replaceShared(initialPageData.shared); + replaceInPlace(shared, initialPageData.shared); setErrors(initialPageData.errors || {}); + replaceInPlace(params, initialPageData.params); setRouteParams(initialPageData.params || {}); }); @@ -254,7 +258,7 @@ function App() { value: reactiveRouter, get children() { return createComponent(ParamsContext.Provider, { - get value() { return routeParams(); }, + value: params, get children() { return createComponent(NavigationContext.Provider, { value: navigation, diff --git a/packages/solid/test/integration/use-params-navigation.test.ts b/packages/solid/test/integration/use-params-navigation.test.ts new file mode 100644 index 000000000..4b5258cb3 --- /dev/null +++ b/packages/solid/test/integration/use-params-navigation.test.ts @@ -0,0 +1,107 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { chromium } from 'playwright'; +import { createServer } from 'vite'; +import solid from 'vite-plugin-solid'; +import { expect, it } from 'vite-plus/test'; +import { pagesPlugin } from '../../src/plugin-pages.ts'; + +const PAGE = `import { Link, useParams } from "@void/solid"; +export default function Post(props) { + const params = useParams(); + return
+

{props.title}

+

{params.slug}

+ Post b +
; +}`; + +const LAYOUT = `import { useParams } from "@void/solid"; +export default function Layout(props) { + const params = useParams(); + return

{params.slug}

{props.children}
; +}`; + +function pageData(slug: string) { + return { + component: 'posts/[slug]', + props: { title: `Post ${slug}` }, + params: { slug }, + shared: {}, + url: `/posts/${slug}`, + }; +} + +// The document a Void Worker sends for a client-rendered page. It boots the real +// generated pages client, which then fetches pageData() as JSON on a Link navigation. +function clientDocument(slug: string): string { + return ` + +
+`; +} + +it('useParams() follows navigation between two params of the same page', async () => { + const root = mkdtempSync(join(import.meta.dirname, '.tmp-use-params-')); + const pages = join(root, 'pages'); + mkdirSync(join(pages, 'posts'), { recursive: true }); + writeFileSync(join(pages, 'layout.tsx'), LAYOUT); + writeFileSync(join(pages, 'posts', '[slug].tsx'), PAGE); + + const browser = await chromium.launch(); + const server = await createServer({ + root, + configFile: false, + cacheDir: join(root, '.vite'), + logLevel: 'silent', + appType: 'custom', + server: { port: 0, host: '127.0.0.1' }, + resolve: { alias: { '@void/solid': join(import.meta.dirname, '../../src/runtime/index.ts') } }, + plugins: [ + solid(), + pagesPlugin(pages, { root }), + { + name: 'test:void-page-responses', + configureServer(viteServer) { + viteServer.middlewares.use((req, res, next) => { + const slug = /^\/posts\/(\w+)$/.exec(req.url ?? '')?.[1]; + if (!slug) { + next(); + return; + } + if (req.headers['x-voidpages']) { + res.setHeader('Content-Type', 'application/json'); + res.setHeader('X-VoidPages', 'true'); + res.end(JSON.stringify(pageData(slug))); + return; + } + res.setHeader('Content-Type', 'text/html'); + res.end(clientDocument(slug)); + }); + }, + }, + ], + }); + try { + await server.listen(); + const page = await browser.newPage(); + await page.goto(`${server.resolvedUrls!.local[0]}posts/a`); + await page.waitForSelector('#app[data-hydrated="true"]'); + expect(await page.textContent('#slug')).toBe('a'); + expect(await page.textContent('#layout-slug')).toBe('a'); + + // A full reload would also render "b", so check that the document survives. + await page.evaluate(() => { + document.documentElement.dataset.sameDocument = 'true'; + }); + await page.click('a[href="/posts/b"]'); + await page.waitForFunction(() => document.getElementById('title')?.textContent === 'Post b'); + expect(await page.textContent('#slug')).toBe('b'); + expect(await page.textContent('#layout-slug')).toBe('b'); + expect(await page.getAttribute('html', 'data-same-document')).toBe('true'); + } finally { + await server.close(); + await browser.close(); + rmSync(root, { recursive: true, force: true }); + } +});