Skip to content

Commit 5495b47

Browse files
authored
fix(next): sync modular dashboard widgets when server component re-renders (#15439)
## Problem When a widget updates query parameters via `router.push` (e.g., incrementing a page counter), the server component `ModularDashboard` correctly re-renders with updated `req.query` values and creates a new `serverLayout` array containing the fresh server-rendered widget components. However, the client component `ModularDashboardClient` didn't reflect these changes because `useDashboardLayout` only used `initialLayout` on mount via `useState`, which ignores subsequent prop changes after the initial render. This meant that even though the server component was producing new data with updated query params, the client component's state remained stale and widgets continued displaying the old values until a full page refresh occurred. ## Use-Case Example One example use case would be rendering a list view table within a widget. When you perform pagination or sort columns, it changes the query params, expecting the RSC to re-run, otherwise the list won't react to these actions. ## Solution Added a `useEffect` hook in `useDashboardLayout` that syncs the `currentLayout` state whenever the `initialLayout` prop changes.
1 parent d6aa6cc commit 5495b47

5 files changed

Lines changed: 119 additions & 4 deletions

File tree

‎packages/next/src/views/Dashboard/Default/ModularDashboard/useDashboardLayout.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
usePreferences,
1010
useServerFunctions,
1111
}from'@payloadcms/ui'
12-
importReact,{useCallback,useState}from'react'
12+
importReact,{useCallback,useEffect,useState}from'react'
1313

1414
importtype{WidgetInstanceClient,WidgetItem}from'./index.client.js'
1515
importtype{GetDefaultLayoutServerFnReturnType}from'./renderWidget/getDefaultLayoutServerFn.js'
@@ -25,6 +25,13 @@ export function useDashboardLayout(initialLayout: WidgetInstanceClient[]) {
2525
constcancelModalSlug='cancel-dashboard-changes'
2626
const{ serverFunction }=useServerFunctions()
2727

28+
// Sync state when initialLayout prop changes (e.g., when query params change and server component re-renders)
29+
useEffect(()=>{
30+
if(!isEditing){
31+
setCurrentLayout(initialLayout)
32+
}
33+
},[initialLayout,isEditing])
34+
2835
constsaveLayout=useCallback(async()=>{
2936
try{
3037
constlayoutData: WidgetItem[]=currentLayout.map((item)=>item.item)
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/* eslint-disable no-restricted-exports */
2+
3+
import{typeWidgetServerProps}from'payload'
4+
importReactfrom'react'
5+
6+
import{PageQueryButton}from'./PageQueryButton.client.js'
7+
8+
exportdefaultfunctionPageQuery({ req }: WidgetServerProps){
9+
// Get page from query params
10+
constqueryPage=req.query?.page
11+
constcurrentPage=typeofqueryPage==='string' ? parseInt(queryPage,10) : 0
12+
13+
return(
14+
<div
15+
className="page-query-widget card"
16+
style={{
17+
display: 'flex',
18+
flexDirection: 'column',
19+
gap: '16px',
20+
height: '100%',
21+
padding: '16px',
22+
}}
23+
>
24+
<div>
25+
<h3style={{fontSize: '16px',fontWeight: 600,margin: 0}}>Page Query Widget</h3>
26+
<pstyle={{color: '#6b7280',fontSize: '14px',margin: '8px 0 0 0'}}>
27+
Current page from query: <strong>{currentPage}</strong>
28+
</p>
29+
</div>
30+
31+
<PageQueryButtoncurrentPage={currentPage}/>
32+
</div>
33+
)
34+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
'use client'
2+
3+
import{usePathname,useRouter,useSearchParams}from'next/navigation.js'
4+
import{useCallback}from'react'
5+
6+
typePageQueryButtonProps={
7+
currentPage: number
8+
}
9+
10+
exportfunctionPageQueryButton({ currentPage }: PageQueryButtonProps){
11+
constrouter=useRouter()
12+
constpathname=usePathname()
13+
constsearchParams=useSearchParams()
14+
15+
consthandleIncrement=useCallback(()=>{
16+
constparams=newURLSearchParams(searchParams.toString())
17+
constnewPage=currentPage+1
18+
params.set('page',String(newPage))
19+
router.push(`${pathname}?${params.toString()}`)
20+
},[currentPage,pathname,router,searchParams])
21+
22+
return(
23+
<button
24+
onClick={handleIncrement}
25+
style={{
26+
backgroundColor: '#2563eb',
27+
border: 'none',
28+
borderRadius: '6px',
29+
color: 'white',
30+
cursor: 'pointer',
31+
fontSize: '14px',
32+
fontWeight: 500,
33+
padding: '8px 16px',
34+
}}
35+
type="button"
36+
>
37+
Increment Page ({currentPage}{currentPage+1})
38+
</button>
39+
)
40+
}

‎test/dashboard/config.ts‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,6 @@ const dirname = path.dirname(filename)
1313

1414
exportdefaultbuildConfigWithDefaults({
1515
admin: {
16-
importMap: {
17-
baseDir: path.resolve(dirname),
18-
},
1916
components: {
2017
afterDashboard: ['./components/BeforeOrAfterDashboard.js'],
2118
beforeDashboard: ['./components/BeforeOrAfterDashboard.js'],
@@ -78,8 +75,16 @@ export default buildConfigWithDefaults({
7875
label: ({ i18n })=>(i18n.language==='es' ? 'Gráfico de Ingresos' : 'Revenue Chart'),
7976
minWidth: 'medium',
8077
},
78+
{
79+
slug: 'page-query',
80+
ComponentPath: './components/PageQuery.tsx#default',
81+
label: 'Page Query Widget',
82+
},
8183
],
8284
},
85+
importMap: {
86+
baseDir: path.resolve(dirname),
87+
},
8388
},
8489
collections: [
8590
Tickets,

‎test/dashboard/e2e.spec.ts‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,4 +199,33 @@ describe('Dashboard', () => {
199199
expect(labels).toContain('Collections')
200200
}).toPass({timeout: 1000})
201201
})
202+
203+
test('widget re-renders when query params change (= modular dashboard RSC rerenders)',async({
204+
page,
205+
})=>{
206+
constd=newDashboardHelper(page)
207+
awaitd.setEditing()
208+
awaitd.addWidget('page query')
209+
awaitd.assertWidget(8,'page-query','x-small')
210+
awaitd.saveChangesAndValidate()
211+
212+
// Find the page-query widget
213+
constpageQueryWidget=page.locator('.page-query-widget')
214+
awaitexpect(pageQueryWidget).toBeVisible()
215+
216+
// Initially, page should be 0 (default)
217+
awaitexpect(pageQueryWidget.getByText(/Currentpagefromquery:0/)).toBeVisible()
218+
219+
// Click the increment button
220+
constincrementButton=pageQueryWidget.getByRole('button',{name: /IncrementPage/})
221+
awaitincrementButton.click()
222+
223+
// The page number should update to 1 without a page refresh
224+
// This test will fail until the server component re-renders when query params change
225+
awaitexpect(pageQueryWidget.getByText(/Currentpagefromquery:1/)).toBeVisible()
226+
227+
// Click again to increment to 2
228+
awaitincrementButton.click()
229+
awaitexpect(pageQueryWidget.getByText(/Currentpagefromquery:2/)).toBeVisible()
230+
})
202231
})

0 commit comments

Comments
 (0)