1+ diff --git var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/epicshop/diff/advanced-react-apis/09.03.solution/dn2ncwjsbmo/index.test.ts var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/epicshop/diff/advanced-react-apis/09.03.solution/dn2ncwjsbmo/index.test.ts
2+ new file mode 100644
3+ index 0000000..e69de29
4+ diff --git var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/epicshop/diff/advanced-react-apis/playground/dn2ncwjsbmo/index.tsx var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/epicshop/diff/advanced-react-apis/09.03.solution/dn2ncwjsbmo/index.tsx
5+ index 4d68325..fd576f7 100644
6+ --- var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/epicshop/diff/advanced-react-apis/playground/dn2ncwjsbmo/index.tsx
7+ +++ var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/epicshop/diff/advanced-react-apis/09.03.solution/dn2ncwjsbmo/index.tsx
8+ @@ -1,190 +1,54 @@
9+ -import { createContext, useEffect, useState, use, useCallback } from 'react'
10+ +import { Suspense, useSyncExternalStore } from 'react'
11+ import * as ReactDOM from 'react-dom/client'
12+ -import {
13+ - type BlogPost,
14+ - generateGradient,
15+ - getMatchingPosts,
16+ -} from '#shared/blog-posts'
17+ -import { setGlobalSearchParams } from '#shared/utils'
18+
19+ -type SearchParamsTuple = readonly [
20+ - URLSearchParams,
21+ - typeof setGlobalSearchParams,
22+ -]
23+ -const SearchParamsContext = createContext<SearchParamsTuple>([
24+ - new URLSearchParams(window.location.search),
25+ - setGlobalSearchParams,
26+ -])
27+ -
28+ -function SearchParamsProvider({ children }: { children: React.ReactNode }) {
29+ - const [searchParams, setSearchParamsState] = useState(
30+ - () => new URLSearchParams(window.location.search),
31+ - )
32+ +export function makeMediaQueryStore(mediaQuery: string) {
33+ + function getSnapshot() {
34+ + return window.matchMedia(mediaQuery).matches
35+ + }
36+
37+ - useEffect(() => {
38+ - function updateSearchParams() {
39+ - setSearchParamsState((prevParams) => {
40+ - const newParams = new URLSearchParams(window.location.search)
41+ - return prevParams.toString() === newParams.toString()
42+ - ? prevParams
43+ - : newParams
44+ - })
45+ + function subscribe(callback: () => void) {
46+ + const mediaQueryList = window.matchMedia(mediaQuery)
47+ + mediaQueryList.addEventListener('change', callback)
48+ + return () => {
49+ + mediaQueryList.removeEventListener('change', callback)
50+ }
51+ - window.addEventListener('popstate', updateSearchParams)
52+ - return () => window.removeEventListener('popstate', updateSearchParams)
53+ - }, [])
54+ -
55+ - const setSearchParams = useCallback(
56+ - (...args: Parameters<typeof setGlobalSearchParams>) => {
57+ - const searchParams = setGlobalSearchParams(...args)
58+ - setSearchParamsState((prevParams) => {
59+ - return prevParams.toString() === searchParams.toString()
60+ - ? prevParams
61+ - : searchParams
62+ - })
63+ - return searchParams
64+ - },
65+ - [],
66+ - )
67+ -
68+ - const searchParamsTuple = [searchParams, setSearchParams] as const
69+ -
70+ - return (
71+ - <SearchParamsContext value={searchParamsTuple}>
72+ - {children}
73+ - </SearchParamsContext>
74+ - )
75+ -}
76+ -
77+ -function useSearchParams() {
78+ - return use(SearchParamsContext)
79+ -}
80+ -
81+ -const getQueryParam = (params: URLSearchParams) => params.get('query') ?? ''
82+ -
83+ -function App() {
84+ - return (
85+ - <SearchParamsProvider>
86+ - <div className="app">
87+ - <Form />
88+ - <MatchingPosts />
89+ - </div>
90+ - </SearchParamsProvider>
91+ - )
92+ -}
93+ -
94+ -function Form() {
95+ - const [searchParams, setSearchParams] = useSearchParams()
96+ - const query = getQueryParam(searchParams)
97+ -
98+ - const words = query.split(' ').map((w) => w.trim())
99+ -
100+ - const dogChecked = words.includes('dog')
101+ - const catChecked = words.includes('cat')
102+ - const caterpillarChecked = words.includes('caterpillar')
103+ -
104+ - function handleCheck(tag: string, checked: boolean) {
105+ - const newWords = checked ? [...words, tag] : words.filter((w) => w !== tag)
106+ - setSearchParams(
107+ - { query: newWords.filter(Boolean).join(' ').trim() },
108+ - { replace: true },
109+ - )
110+ }
111+
112+ - return (
113+ - <form onSubmit={(e) => e.preventDefault()}>
114+ - <div>
115+ - <label htmlFor="searchInput">Search:</label>
116+ - <input
117+ - id="searchInput"
118+ - name="query"
119+ - type="search"
120+ - value={query}
121+ - onChange={(e) =>
122+ - setSearchParams({ query: e.currentTarget.value }, { replace: true })
123+ - }
124+ - />
125+ - </div>
126+ - <div>
127+ - <label>
128+ - <input
129+ - type="checkbox"
130+ - checked={dogChecked}
131+ - onChange={(e) => handleCheck('dog', e.currentTarget.checked)}
132+ - />{' '}
133+ - 🐶 dog
134+ - </label>
135+ - <label>
136+ - <input
137+ - type="checkbox"
138+ - checked={catChecked}
139+ - onChange={(e) => handleCheck('cat', e.currentTarget.checked)}
140+ - />{' '}
141+ - 🐱 cat
142+ - </label>
143+ - <label>
144+ - <input
145+ - type="checkbox"
146+ - checked={caterpillarChecked}
147+ - onChange={(e) =>
148+ - handleCheck('caterpillar', e.currentTarget.checked)
149+ - }
150+ - />{' '}
151+ - 🐛 caterpillar
152+ - </label>
153+ - </div>
154+ - </form>
155+ - )
156+ + return function useMediaQuery() {
157+ + return useSyncExternalStore(subscribe, getSnapshot)
158+ + }
159+ }
160+
161+ -function MatchingPosts() {
162+ - const [searchParams] = useSearchParams()
163+ - const query = getQueryParam(searchParams)
164+ - const matchingPosts = getMatchingPosts(query)
165+ +const useNarrowMediaQuery = makeMediaQueryStore('(max-width: 600px)')
166+
167+ - return (
168+ - <ul className="post-list">
169+ - {matchingPosts.map((post) => (
170+ - <Card key={post.id} post={post} />
171+ - ))}
172+ - </ul>
173+ - )
174+ +function NarrowScreenNotifier() {
175+ + const isNarrow = useNarrowMediaQuery()
176+ + return isNarrow ? 'You are on a narrow screen' : 'You are on a wide screen'
177+ }
178+
179+ -function Card({ post }: { post: BlogPost }) {
180+ - const [isFavorited, setIsFavorited] = useState(false)
181+ +function App() {
182+ return (
183+ - <li>
184+ - {isFavorited ? (
185+ - <button
186+ - aria-label="Remove favorite"
187+ - onClick={() => setIsFavorited(false)}
188+ - >
189+ - ❤️
190+ - </button>
191+ - ) : (
192+ - <button aria-label="Add favorite" onClick={() => setIsFavorited(true)}>
193+ - 🤍
194+ - </button>
195+ - )}
196+ - <div
197+ - className="post-image"
198+ - style={{ background: generateGradient(post.id) }}
199+ - />
200+ - <a
201+ - href={post.id}
202+ - onClick={(event) => {
203+ - event.preventDefault()
204+ - alert(`Great! Let's go to ${post.id}!`)
205+ - }}
206+ - >
207+ - <h2>{post.title}</h2>
208+ - <p>{post.description}</p>
209+ - </a>
210+ - </li>
211+ + <div>
212+ + <div>This is your narrow screen state:</div>
213+ + <Suspense fallback="">
214+ + <NarrowScreenNotifier />
215+ + </Suspense>
216+ + </div>
217+ )
218+ }
219+
220+ const rootEl = document.createElement('div')
221+ document.body.append(rootEl)
222+ -ReactDOM.createRoot(rootEl).render(<App />)
223+ +// 🦉 here's how we pretend we're server-rendering
224+ +rootEl.innerHTML = (await import('react-dom/server')).renderToString(<App />)
225+ +
226+ +// 🦉 here's how we simulate a delay in hydrating with client-side js
227+ +await new Promise((resolve) => setTimeout(resolve, 1000))
228+ +
229+ +ReactDOM.hydrateRoot(rootEl, <App />, {
230+ + onRecoverableError(error) {
231+ + if (String(error).includes('Missing getServerSnapshot')) return
232+ +
233+ + console.error(error)
234+ + },
235+ +})
0 commit comments