Skip to content

Commit b8f18d4

Browse files
robertbpughclaude
andauthored
Subscribers: remember the chosen page size (#53903)
* Subscribers: remember the chosen page size The page size lived only in the URL, so reopening the Subscribers screen reset it to 20. Save it per site in localStorage and use it when the URL has none. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MFjM4HdEDHT7uiZDdjhUMQ * Subscribers: don't overwrite the remembered page size from a link Opening a link with a pp param saved that size on mount, replacing the admin's own choice. Save only when the page size changes after the screen opens. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MFjM4HdEDHT7uiZDdjhUMQ * Subscribers: scope the remembered page size to site and user Two people sharing a browser, or sites without a WordPress.com id, shared one remembered size. Key it on both ids and skip storage when either is unknown. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MFjM4HdEDHT7uiZDdjhUMQ * Subscribers: save the page size from the setter, not an effect StrictMode runs effects twice on mount, so the skip-first-save ref let the second run through: opening the screen stored the default, and a shared link's pp overwrote the remembered size. Saving only when the admin changes the page size avoids both. Tests now render in StrictMode. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MFjM4HdEDHT7uiZDdjhUMQ * Subscribers: reset the site mock before each page-size test Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MFjM4HdEDHT7uiZDdjhUMQ --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent fb5e4d7 commit b8f18d4

4 files changed

Lines changed: 160 additions & 6 deletions

File tree

‎projects/packages/newsletter/_inc/subscribers/lib/use-view-state.ts‎

Lines changed: 75 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
import { useEffect, useRef, useState } from '@wordpress/element';
1+
import { getScriptData } from '@automattic/jetpack-script-data';
2+
import { useCallback, useEffect, useRef, useState } from '@wordpress/element';
3+
import { getBlogId } from './site';
24
import type { SubscribersFilter, SubscribersSortField } from '../data/types';
35
import type { View } from '@wordpress/dataviews';
46

@@ -116,18 +118,85 @@ function writeViewToUrl( view: View ): URLSearchParams {
116118
return params;
117119
}
118120

121+
/**
122+
* `localStorage` key for the remembered page size, per site and per user, or null when either id
123+
* is unknown (so nothing is shared across sites or people using one browser).
124+
*
125+
* @return Storage key, or null.
126+
*/
127+
function perPageStorageKey(): string | null {
128+
const blogId = getBlogId();
129+
const userId = getScriptData()?.user?.current_user?.id;
130+
return blogId && userId
131+
? `jetpack-newsletter:subscribers-per-page:${ blogId }:${ userId }`
132+
: null;
133+
}
134+
135+
/**
136+
* Read the page size the admin last chose, if any.
137+
*
138+
* @return Remembered page size, or undefined.
139+
*/
140+
function readSavedPerPage(): number | undefined {
141+
const key = perPageStorageKey();
142+
if ( ! key ) {
143+
return undefined;
144+
}
145+
try {
146+
const saved = Number( window.localStorage.getItem( key ) );
147+
return Number.isFinite( saved ) && saved > 0 ? saved : undefined;
148+
} catch {
149+
// localStorage can be unavailable (privacy mode, blocked storage).
150+
return undefined;
151+
}
152+
}
153+
154+
/**
155+
* Remember the page size for the next visit.
156+
*
157+
* @param perPage - Page size to remember.
158+
*/
159+
function savePerPage( perPage: number | undefined ): void {
160+
const key = perPageStorageKey();
161+
if ( ! key || ! perPage ) {
162+
return;
163+
}
164+
try {
165+
window.localStorage.setItem( key, String( perPage ) );
166+
} catch {
167+
// localStorage can be unavailable or full; the URL still carries the choice.
168+
}
169+
}
170+
119171
/**
120172
* View-state hook with URL persistence. Initializes from `window.location.search` (so back/forward
121173
* and bookmarks restore the table state) and keeps the URL in sync via `history.replaceState`.
174+
* The page size is also remembered per site and user, so a fresh visit without `pp` keeps the last choice.
122175
*
123176
* @param defaultView - Fallback view when the URL has no relevant params.
124177
* @return Tuple of `[view, setView]`, mirroring `useState`.
125178
*/
126179
export function useViewState( defaultView: View ): [ View, ( next: View ) => void ] {
127-
const [ view, setViewState ] = useState< View >( () => ( {
128-
...defaultView,
129-
...readViewFromUrl(),
130-
} ) );
180+
const [ view, setViewState ] = useState< View >( () => {
181+
const savedPerPage = readSavedPerPage();
182+
return {
183+
...defaultView,
184+
...( savedPerPage ? { perPage: savedPerPage } : {} ),
185+
...readViewFromUrl(),
186+
};
187+
} );
188+
189+
// Save only sizes the admin picks here, not one that arrived in a shared link's `pp` param. This
190+
// runs in the setter rather than an effect, since StrictMode runs effects twice on mount.
191+
const setView = useCallback(
192+
( next: View ) => {
193+
if ( next.perPage !== view.perPage ) {
194+
savePerPage( next.perPage );
195+
}
196+
setViewState( next );
197+
},
198+
[ view.perPage ]
199+
);
131200

132201
// Sync URL when the view changes — but skip the very first render, since we just hydrated
133202
// from the URL and would otherwise trigger an immediate replaceState with the same values.
@@ -144,5 +213,5 @@ export function useViewState( defaultView: View ): [ View, ( next: View ) => voi
144213
window.history.replaceState( window.history.state, '', newUrl );
145214
}, [ view ] );
146215

147-
return [ view, setViewState ];
216+
return [ view, setView ];
148217
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Significance: patch
2+
Type: fixed
3+
4+
Subscribers: Remember the chosen number of subscribers per page.
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { getSiteData } from '@automattic/jetpack-script-data';
2+
import { act, renderHook } from '@testing-library/react';
3+
import { StrictMode } from 'react';
4+
import { useViewState } from '../_inc/subscribers/lib/use-view-state';
5+
import type { View } from '@wordpress/dataviews';
6+
7+
jest.mock( '@automattic/jetpack-script-data', () => ( {
8+
getSiteData: jest.fn( () => ( { wpcom: { blog_id: 123 } } ) ),
9+
getScriptData: jest.fn( () => ( { user: { current_user: { id: 1 } } } ) ),
10+
} ) );
11+
12+
const defaultView: View = { type: 'table', page: 1, perPage: 20 };
13+
14+
// StrictMode, as in wp-admin with SCRIPT_DEBUG on, runs each effect twice on mount.
15+
const openPage = ( search = '' ) => {
16+
window.history.replaceState( null, '', `/wp-admin/admin.php${ search }` );
17+
return renderHook( () => useViewState( defaultView ), { wrapper: StrictMode } );
18+
};
19+
20+
describe( 'useViewState', () => {
21+
beforeEach( () => {
22+
window.localStorage.clear();
23+
jest.mocked( getSiteData ).mockReturnValue( { wpcom: { blog_id: 123 } } as never );
24+
} );
25+
26+
it( 'remembers the chosen page size the next time the screen opens', () => {
27+
const { result: firstVisit, unmount } = openPage( '?page=jetpack-newsletter' );
28+
act( () => firstVisit.current[ 1 ]( { ...firstVisit.current[ 0 ], perPage: 50 } ) );
29+
unmount();
30+
31+
const { result: secondVisit } = openPage( '?page=jetpack-newsletter' );
32+
33+
expect( secondVisit.current[ 0 ].perPage ).toBe( 50 );
34+
} );
35+
36+
it( 'lets a page size in the URL win over the remembered one', () => {
37+
const { result: firstVisit, unmount } = openPage( '?page=jetpack-newsletter' );
38+
act( () => firstVisit.current[ 1 ]( { ...firstVisit.current[ 0 ], perPage: 50 } ) );
39+
unmount();
40+
41+
const { result: secondVisit } = openPage( '?page=jetpack-newsletter&pp=100' );
42+
43+
expect( secondVisit.current[ 0 ].perPage ).toBe( 100 );
44+
} );
45+
46+
it( 'keeps the remembered page size after opening a link with a different one', () => {
47+
const { result: firstVisit, unmount } = openPage( '?page=jetpack-newsletter' );
48+
act( () => firstVisit.current[ 1 ]( { ...firstVisit.current[ 0 ], perPage: 50 } ) );
49+
unmount();
50+
openPage( '?page=jetpack-newsletter&pp=100' ).unmount();
51+
52+
const { result: laterVisit } = openPage( '?page=jetpack-newsletter' );
53+
54+
expect( laterVisit.current[ 0 ].perPage ).toBe( 50 );
55+
} );
56+
57+
it( 'remembers nothing until a page size is picked', () => {
58+
openPage( '?page=jetpack-newsletter' ).unmount();
59+
60+
expect( window.localStorage ).toHaveLength( 0 );
61+
} );
62+
63+
it( 'remembers nothing when the site has no WordPress.com id', () => {
64+
jest.mocked( getSiteData ).mockReturnValue( { wpcom: { blog_id: 0 } } as never );
65+
const { result: firstVisit, unmount } = openPage( '?page=jetpack-newsletter' );
66+
act( () => firstVisit.current[ 1 ]( { ...firstVisit.current[ 0 ], perPage: 50 } ) );
67+
unmount();
68+
69+
expect( window.localStorage ).toHaveLength( 0 );
70+
} );
71+
72+
it( 'uses the default page size when nothing is remembered', () => {
73+
const { result } = openPage( '?page=jetpack-newsletter' );
74+
75+
expect( result.current[ 0 ].perPage ).toBe( 20 );
76+
} );
77+
} );
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Significance: patch
2+
Type: bugfix
3+
4+
Newsletter: Remember the chosen number of subscribers per page on the Subscribers screen.

0 commit comments

Comments
 (0)