[NTP] Simplify state provider API (#32643)
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
/* Copyright (c) 2025 The Brave Authors. All rights reserved.
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
||||
* You can obtain one at https://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import * as React from 'react'
|
||||
import { render, screen, act } from '@testing-library/react'
|
||||
|
||||
import { createStateProvider } from './state_provider'
|
||||
import { StateStore } from './state_store'
|
||||
|
||||
interface TestState {
|
||||
count: number
|
||||
name: string
|
||||
}
|
||||
|
||||
function defaultState(): TestState {
|
||||
return { count: 0, name: 'test' }
|
||||
}
|
||||
|
||||
interface TestActions {
|
||||
increment(): void
|
||||
setName(name: string): void
|
||||
}
|
||||
|
||||
function createHandler(store: StateStore<TestState>): TestActions {
|
||||
return {
|
||||
increment() {
|
||||
store.update((s) => ({ count: s.count + 1 }))
|
||||
},
|
||||
setName(name: string) {
|
||||
store.update({ name })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function flushMicrotasks() {
|
||||
return act(() => new Promise<void>((resolve) => queueMicrotask(resolve)))
|
||||
}
|
||||
|
||||
describe('createStateProvider', () => {
|
||||
describe('Provider', () => {
|
||||
it('renders children', () => {
|
||||
const TestProvider = createStateProvider(defaultState(), createHandler)
|
||||
render(
|
||||
<TestProvider>
|
||||
<div data-testid='child'>Hello</div>
|
||||
</TestProvider>,
|
||||
)
|
||||
expect(screen.getByTestId('child')).toHaveTextContent('Hello')
|
||||
})
|
||||
|
||||
it('exposes store to window.appState when name prop is provided', () => {
|
||||
const TestProvider = createStateProvider(defaultState(), createHandler)
|
||||
render(
|
||||
<TestProvider name='test'>
|
||||
<div />
|
||||
</TestProvider>,
|
||||
)
|
||||
expect((self as any).appState.test).toBeDefined()
|
||||
expect((self as any).appState.test.getState()).toEqual(defaultState())
|
||||
})
|
||||
})
|
||||
|
||||
describe('useState', () => {
|
||||
it('returns the mapped state value', () => {
|
||||
const TestProvider = createStateProvider(defaultState(), createHandler)
|
||||
|
||||
function TestComponent() {
|
||||
const count = TestProvider.useState((s) => s.count)
|
||||
return <div data-testid='count'>{count}</div>
|
||||
}
|
||||
|
||||
render(
|
||||
<TestProvider>
|
||||
<TestComponent />
|
||||
</TestProvider>,
|
||||
)
|
||||
expect(screen.getByTestId('count')).toHaveTextContent('0')
|
||||
})
|
||||
|
||||
it('updates when state changes', async () => {
|
||||
const TestProvider = createStateProvider(defaultState(), createHandler)
|
||||
|
||||
function TestComponent() {
|
||||
const count = TestProvider.useState((s) => s.count)
|
||||
const actions = TestProvider.useActions()
|
||||
return (
|
||||
<button
|
||||
data-testid='button'
|
||||
onClick={() => actions.increment()}
|
||||
>
|
||||
{count}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
render(
|
||||
<TestProvider>
|
||||
<TestComponent />
|
||||
</TestProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('button')).toHaveTextContent('0')
|
||||
screen.getByTestId('button').click()
|
||||
await flushMicrotasks()
|
||||
expect(screen.getByTestId('button')).toHaveTextContent('1')
|
||||
})
|
||||
|
||||
it('throws when used outside provider', () => {
|
||||
const TestProvider = createStateProvider(defaultState(), createHandler)
|
||||
|
||||
function TestComponent() {
|
||||
TestProvider.useState((s) => s.count)
|
||||
return null
|
||||
}
|
||||
|
||||
expect(() => render(<TestComponent />)).toThrow(
|
||||
'State context value has not been set',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useActions', () => {
|
||||
it('returns the actions object', () => {
|
||||
const TestProvider = createStateProvider(defaultState(), createHandler)
|
||||
let capturedActions: TestActions | null = null
|
||||
|
||||
function TestComponent() {
|
||||
capturedActions = TestProvider.useActions()
|
||||
return null
|
||||
}
|
||||
|
||||
render(
|
||||
<TestProvider>
|
||||
<TestComponent />
|
||||
</TestProvider>,
|
||||
)
|
||||
|
||||
expect(capturedActions).not.toBeNull()
|
||||
expect(typeof capturedActions!.increment).toBe('function')
|
||||
expect(typeof capturedActions!.setName).toBe('function')
|
||||
})
|
||||
|
||||
it('throws when used outside provider', () => {
|
||||
const TestProvider = createStateProvider(defaultState(), createHandler)
|
||||
|
||||
function TestComponent() {
|
||||
TestProvider.useActions()
|
||||
return null
|
||||
}
|
||||
|
||||
expect(() => render(<TestComponent />)).toThrow(
|
||||
'State context value has not been set',
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -5,15 +5,96 @@
|
||||
|
||||
import * as React from 'react'
|
||||
|
||||
import { Store, createStore } from './store'
|
||||
import { StateStore, createStateStore } from './state_store'
|
||||
|
||||
/**
|
||||
* Creates a React context provider component for managing application state.
|
||||
* The returned Provider component has `useState` and `useActions` hooks
|
||||
* attached as static methods for accessing state and actions within the
|
||||
* provider tree.
|
||||
*
|
||||
* @param initialState - The initial state object for the store.
|
||||
* @param createHandler - A function that receives the state store and returns
|
||||
* an action handler. Action handlers typically call `store.update()` to
|
||||
* modify state.
|
||||
* @returns A Provider component with attached `useState` and `useActions`
|
||||
* hooks.
|
||||
*
|
||||
* @example
|
||||
* // 1. Define state and actions:
|
||||
*
|
||||
* interface AppState {
|
||||
* count: number
|
||||
* name: string
|
||||
* }
|
||||
*
|
||||
* function defaultState(): AppState {
|
||||
* return { count: 0, name: 'test' }
|
||||
* }
|
||||
*
|
||||
* interface AppActions {
|
||||
* increment(): void
|
||||
* setName(name: string): void
|
||||
* }
|
||||
*
|
||||
* function createHandler(store: StateStore<AppState>): AppActions {
|
||||
* return {
|
||||
* increment() {
|
||||
* store.update((s) => ({ count: s.count + 1 }))
|
||||
* },
|
||||
* setName(name) {
|
||||
* store.update({ name })
|
||||
* },
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* // 2. Create the provider:
|
||||
*
|
||||
* export const AppStateProvider = createStateProvider(
|
||||
* defaultState(),
|
||||
* createHandler
|
||||
* )
|
||||
*
|
||||
* // Export hooks for convenience:
|
||||
*
|
||||
* export const useAppState = AppStateProvider.useState
|
||||
* export const useAppActions = AppStateProvider.useActions
|
||||
*
|
||||
* // Wrap your app with the provider. The optional `name` prop exposes the
|
||||
* // store to `window.appState[name]` for debugging.
|
||||
*
|
||||
* function App() {
|
||||
* return (
|
||||
* <AppStateProvider name='myApp'>
|
||||
* <MyComponent />
|
||||
* </AppStateProvider>
|
||||
* )
|
||||
* }
|
||||
*
|
||||
* // Use hooks in components to access state and actions:
|
||||
*
|
||||
* function MyComponent() {
|
||||
* // Select specific state values with a mapping function.
|
||||
* const count = useAppState((s) => s.count)
|
||||
* const name = useAppState((s) => s.name)
|
||||
*
|
||||
* // Get actions to update state.
|
||||
* const actions = useAppActions()
|
||||
*
|
||||
* return (
|
||||
* <button onClick={() => actions.increment()}>
|
||||
* {count}
|
||||
* </button>
|
||||
* )
|
||||
* }
|
||||
*/
|
||||
export function createStateProvider<State, Actions>(
|
||||
initialState: State,
|
||||
createHandler: (store: Store<State>) => Actions,
|
||||
createHandler: (store: StateStore<State>) => Actions,
|
||||
) {
|
||||
interface ContextValue {
|
||||
store: Store<State>
|
||||
handler: Actions
|
||||
store: StateStore<State>
|
||||
actions: Actions
|
||||
}
|
||||
|
||||
const context = React.createContext<ContextValue | null>(null)
|
||||
@@ -27,7 +108,7 @@ export function createStateProvider<State, Actions>(
|
||||
}
|
||||
|
||||
function useActions(): Actions {
|
||||
return useContextValue().handler
|
||||
return useContextValue().actions
|
||||
}
|
||||
|
||||
function useState<T>(map: (state: State) => T): T {
|
||||
@@ -44,15 +125,15 @@ export function createStateProvider<State, Actions>(
|
||||
|
||||
interface ProviderProps {
|
||||
name?: string
|
||||
createHandler?: (store: Store<State>) => Actions
|
||||
createHandler?: (store: StateStore<State>) => Actions
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
function Provider(props: ProviderProps) {
|
||||
const value = React.useMemo(() => {
|
||||
const store = createStore(initialState)
|
||||
const handler = (props.createHandler ?? createHandler)(store)
|
||||
return { store, handler }
|
||||
const store = createStateStore(initialState)
|
||||
const actions = (props.createHandler ?? createHandler)(store)
|
||||
return { store, actions }
|
||||
}, [props.createHandler])
|
||||
|
||||
React.useEffect(() => {
|
||||
|
||||
+5
-3
@@ -8,7 +8,7 @@ type Listener<State> = (state: State) => void
|
||||
type UpdateFunction<State> = (state: State) => Partial<State>
|
||||
|
||||
// A simple object-state store.
|
||||
export interface Store<State> {
|
||||
export interface StateStore<State> {
|
||||
// Returns the current state of the store.
|
||||
getState: () => State
|
||||
|
||||
@@ -19,11 +19,13 @@ export interface Store<State> {
|
||||
|
||||
// Adds a listener that will be notified when the state store changes. The
|
||||
// listener will not be notified immediately. Returns a function that will
|
||||
// remove the listener from store.
|
||||
// remove the listener from the store.
|
||||
addListener: (listener: Listener<State>) => () => void
|
||||
}
|
||||
|
||||
export function createStore<State>(initialState: State): Store<State> {
|
||||
export function createStateStore<State>(
|
||||
initialState: State,
|
||||
): StateStore<State> {
|
||||
const listeners = new Set<Listener<State>>()
|
||||
const state = { ...initialState }
|
||||
let notificationQueued = false
|
||||
@@ -6,13 +6,13 @@
|
||||
import { loadTimeData } from '$web-common/loadTimeData'
|
||||
import { SponsoredRichMediaAdEventHandler } from 'gen/brave/components/ntp_background_images/browser/mojom/ntp_background_images.mojom.m.js'
|
||||
import { NewTabPageProxy } from './new_tab_page_proxy'
|
||||
import { Store } from '../lib/store'
|
||||
import { StateStore } from '../lib/state_store'
|
||||
import { debounce } from '$web-common/debounce'
|
||||
import { preloadedBackgrounds } from './background_images/preloaded'
|
||||
import { BackgroundState, BackgroundActions } from './background_state'
|
||||
|
||||
export function createBackgroundHandler(
|
||||
store: Store<BackgroundState>,
|
||||
store: StateStore<BackgroundState>,
|
||||
): BackgroundActions {
|
||||
const newTabProxy = NewTabPageProxy.getInstance()
|
||||
const { handler } = newTabProxy
|
||||
|
||||
@@ -5,11 +5,13 @@
|
||||
|
||||
import { loadTimeData } from '$web-common/loadTimeData'
|
||||
import { NewTabPageProxy } from './new_tab_page_proxy'
|
||||
import { Store } from '../lib/store'
|
||||
import { StateStore } from '../lib/state_store'
|
||||
import { debounce } from '$web-common/debounce'
|
||||
import { NewTabState, NewTabActions } from './new_tab_state'
|
||||
|
||||
export function createNewTabHandler(store: Store<NewTabState>): NewTabActions {
|
||||
export function createNewTabHandler(
|
||||
store: StateStore<NewTabState>,
|
||||
): NewTabActions {
|
||||
const newTabProxy = NewTabPageProxy.getInstance()
|
||||
const { handler } = newTabProxy
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { loadTimeData } from '$web-common/loadTimeData'
|
||||
import { RewardsPageProxy } from '../../../../components/brave_rewards/resources/rewards_page/webui/rewards_page_proxy'
|
||||
import { externalWalletFromExtensionData } from '../../../../components/brave_rewards/resources/shared/lib/external_wallet'
|
||||
import { NewTabPageProxy } from './new_tab_page_proxy'
|
||||
import { Store } from '../lib/store'
|
||||
import { StateStore } from '../lib/state_store'
|
||||
import { debounce } from '$web-common/debounce'
|
||||
import {
|
||||
RewardsState,
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
} from './rewards_state'
|
||||
|
||||
export function createRewardsHandler(
|
||||
store: Store<RewardsState>,
|
||||
store: StateStore<RewardsState>,
|
||||
): RewardsActions {
|
||||
if (!loadTimeData.getBoolean('rewardsFeatureEnabled')) {
|
||||
store.update({ initialized: true })
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { loadTimeData } from '$web-common/loadTimeData'
|
||||
import { SearchBoxProxy } from './search_box_proxy'
|
||||
import { NewTabPageProxy } from './new_tab_page_proxy'
|
||||
import { Store } from '../lib/store'
|
||||
import { StateStore } from '../lib/state_store'
|
||||
import { debounce } from '$web-common/debounce'
|
||||
|
||||
import {
|
||||
@@ -63,7 +63,9 @@ function storeEnabledSearchEngines(engines: Set<string>) {
|
||||
localStorage.setItem(enabledSearchEnginesStorageKey, JSON.stringify(record))
|
||||
}
|
||||
|
||||
export function createSearchHandler(store: Store<SearchState>): SearchActions {
|
||||
export function createSearchHandler(
|
||||
store: StateStore<SearchState>,
|
||||
): SearchActions {
|
||||
if (!loadTimeData.getBoolean('ntpSearchFeatureEnabled')) {
|
||||
return defaultSearchActions()
|
||||
}
|
||||
|
||||
@@ -10,11 +10,11 @@ import {
|
||||
TopSitesActions,
|
||||
TopSitesListKind,
|
||||
} from './top_sites_state'
|
||||
import { Store } from '../lib/store'
|
||||
import { StateStore } from '../lib/state_store'
|
||||
import { debounce } from '$web-common/debounce'
|
||||
|
||||
export function createTopSitesHandler(
|
||||
store: Store<TopSitesState>,
|
||||
store: StateStore<TopSitesState>,
|
||||
): TopSitesActions {
|
||||
const newTabProxy = NewTabPageProxy.getInstance()
|
||||
const { handler } = newTabProxy
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { loadTimeData } from '$web-common/loadTimeData'
|
||||
import * as mojom from 'gen/brave/components/brave_vpn/common/mojom/brave_vpn.mojom.m'
|
||||
import { NewTabPageProxy } from './new_tab_page_proxy'
|
||||
import { Store } from '../lib/store'
|
||||
import { StateStore } from '../lib/state_store'
|
||||
import { debounce } from '$web-common/debounce'
|
||||
import {
|
||||
VpnState,
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
ConnectionState,
|
||||
} from './vpn_state'
|
||||
|
||||
export function createVpnHandler(store: Store<VpnState>): VpnActions {
|
||||
export function createVpnHandler(store: StateStore<VpnState>): VpnActions {
|
||||
if (!loadTimeData.getBoolean('vpnFeatureEnabled')) {
|
||||
store.update({ initialized: true })
|
||||
return defaultVpnActions()
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
||||
* You can obtain one at https://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import { Store } from '../lib/store'
|
||||
import { StateStore } from '../lib/state_store'
|
||||
|
||||
import {
|
||||
BackgroundState,
|
||||
@@ -56,7 +56,7 @@ const sponsoredBackgrounds = {
|
||||
}
|
||||
|
||||
export function createBackgroundHandler(
|
||||
store: Store<BackgroundState>,
|
||||
store: StateStore<BackgroundState>,
|
||||
args: StorybookArgs,
|
||||
): BackgroundActions {
|
||||
store.update({
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
||||
* You can obtain one at https://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import { Store } from '../lib/store'
|
||||
import { StateStore } from '../lib/state_store'
|
||||
|
||||
import {
|
||||
NewTabState,
|
||||
@@ -11,7 +11,9 @@ import {
|
||||
defaultNewTabActions,
|
||||
} from '../state/new_tab_state'
|
||||
|
||||
export function createNewTabHandler(store: Store<NewTabState>): NewTabActions {
|
||||
export function createNewTabHandler(
|
||||
store: StateStore<NewTabState>,
|
||||
): NewTabActions {
|
||||
store.update({
|
||||
initialized: true,
|
||||
showClock: true,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
||||
* You can obtain one at https://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import { Store } from '../lib/store'
|
||||
import { StateStore } from '../lib/state_store'
|
||||
|
||||
import {
|
||||
RewardsState,
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from '../state/rewards_state'
|
||||
|
||||
export function createRewardsHandler(
|
||||
store: Store<RewardsState>,
|
||||
store: StateStore<RewardsState>,
|
||||
): RewardsActions {
|
||||
store.update({
|
||||
initialized: true,
|
||||
|
||||
@@ -3,14 +3,16 @@
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
||||
* You can obtain one at https://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import { Store } from '../lib/store'
|
||||
import { StateStore } from '../lib/state_store'
|
||||
import {
|
||||
SearchState,
|
||||
SearchActions,
|
||||
defaultSearchActions,
|
||||
} from '../state/search_state'
|
||||
|
||||
export function createSearchHandler(store: Store<SearchState>): SearchActions {
|
||||
export function createSearchHandler(
|
||||
store: StateStore<SearchState>,
|
||||
): SearchActions {
|
||||
store.update({
|
||||
initialized: true,
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
||||
* You can obtain one at https://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import { Store } from '../lib/store'
|
||||
import { StateStore } from '../lib/state_store'
|
||||
|
||||
import {
|
||||
TopSitesState,
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
} from '../state/top_sites_state'
|
||||
|
||||
export function createTopSitesHandler(
|
||||
store: Store<TopSitesState>,
|
||||
store: StateStore<TopSitesState>,
|
||||
): TopSitesActions {
|
||||
let lastRemovedSite: TopSite | null = null
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
||||
* You can obtain one at https://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import { Store } from '../lib/store'
|
||||
import { StateStore } from '../lib/state_store'
|
||||
|
||||
import {
|
||||
VpnState,
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
ConnectionState,
|
||||
} from '../state/vpn_state'
|
||||
|
||||
export function createVpnHandler(store: Store<VpnState>): VpnActions {
|
||||
export function createVpnHandler(store: StateStore<VpnState>): VpnActions {
|
||||
store.update({
|
||||
initialized: true,
|
||||
vpnFeatureEnabled: true,
|
||||
|
||||
Reference in New Issue
Block a user