Open kentcdodds opened 1 month ago
@kentcdodds Thank you for your report. I have confirmed the reproduction of the bug.
Hi, @kentcdodds Could you try v0.0.16
?
It's better! Now the diff is showing files, however it's saying that a file which was modified was actually added. Here's my diff example:
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
new file mode 100644
index 0000000..e69de29
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
index 4d68325..fd576f7 100644
--- 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
@@ -1,190 +1,54 @@
-import { createContext, useEffect, useState, use, useCallback } from 'react'
+import { Suspense, useSyncExternalStore } from 'react'
import * as ReactDOM from 'react-dom/client'
-import {
- type BlogPost,
- generateGradient,
- getMatchingPosts,
-} from '#shared/blog-posts'
-import { setGlobalSearchParams } from '#shared/utils'
-type SearchParamsTuple = readonly [
- URLSearchParams,
- typeof setGlobalSearchParams,
-]
-const SearchParamsContext = createContext<SearchParamsTuple>([
- new URLSearchParams(window.location.search),
- setGlobalSearchParams,
-])
-
-function SearchParamsProvider({ children }: { children: React.ReactNode }) {
- const [searchParams, setSearchParamsState] = useState(
- () => new URLSearchParams(window.location.search),
- )
+export function makeMediaQueryStore(mediaQuery: string) {
+ function getSnapshot() {
+ return window.matchMedia(mediaQuery).matches
+ }
- useEffect(() => {
- function updateSearchParams() {
- setSearchParamsState((prevParams) => {
- const newParams = new URLSearchParams(window.location.search)
- return prevParams.toString() === newParams.toString()
- ? prevParams
- : newParams
- })
+ function subscribe(callback: () => void) {
+ const mediaQueryList = window.matchMedia(mediaQuery)
+ mediaQueryList.addEventListener('change', callback)
+ return () => {
+ mediaQueryList.removeEventListener('change', callback)
}
- window.addEventListener('popstate', updateSearchParams)
- return () => window.removeEventListener('popstate', updateSearchParams)
- }, [])
-
- const setSearchParams = useCallback(
- (...args: Parameters<typeof setGlobalSearchParams>) => {
- const searchParams = setGlobalSearchParams(...args)
- setSearchParamsState((prevParams) => {
- return prevParams.toString() === searchParams.toString()
- ? prevParams
- : searchParams
- })
- return searchParams
- },
- [],
- )
-
- const searchParamsTuple = [searchParams, setSearchParams] as const
-
- return (
- <SearchParamsContext value={searchParamsTuple}>
- {children}
- </SearchParamsContext>
- )
-}
-
-function useSearchParams() {
- return use(SearchParamsContext)
-}
-
-const getQueryParam = (params: URLSearchParams) => params.get('query') ?? ''
-
-function App() {
- return (
- <SearchParamsProvider>
- <div className="app">
- <Form />
- <MatchingPosts />
- </div>
- </SearchParamsProvider>
- )
-}
-
-function Form() {
- const [searchParams, setSearchParams] = useSearchParams()
- const query = getQueryParam(searchParams)
-
- const words = query.split(' ').map((w) => w.trim())
-
- const dogChecked = words.includes('dog')
- const catChecked = words.includes('cat')
- const caterpillarChecked = words.includes('caterpillar')
-
- function handleCheck(tag: string, checked: boolean) {
- const newWords = checked ? [...words, tag] : words.filter((w) => w !== tag)
- setSearchParams(
- { query: newWords.filter(Boolean).join(' ').trim() },
- { replace: true },
- )
}
- return (
- <form onSubmit={(e) => e.preventDefault()}>
- <div>
- <label htmlFor="searchInput">Search:</label>
- <input
- id="searchInput"
- name="query"
- type="search"
- value={query}
- onChange={(e) =>
- setSearchParams({ query: e.currentTarget.value }, { replace: true })
- }
- />
- </div>
- <div>
- <label>
- <input
- type="checkbox"
- checked={dogChecked}
- onChange={(e) => handleCheck('dog', e.currentTarget.checked)}
- />{' '}
- 🐶 dog
- </label>
- <label>
- <input
- type="checkbox"
- checked={catChecked}
- onChange={(e) => handleCheck('cat', e.currentTarget.checked)}
- />{' '}
- 🐱 cat
- </label>
- <label>
- <input
- type="checkbox"
- checked={caterpillarChecked}
- onChange={(e) =>
- handleCheck('caterpillar', e.currentTarget.checked)
- }
- />{' '}
- 🐛 caterpillar
- </label>
- </div>
- </form>
- )
+ return function useMediaQuery() {
+ return useSyncExternalStore(subscribe, getSnapshot)
+ }
}
-function MatchingPosts() {
- const [searchParams] = useSearchParams()
- const query = getQueryParam(searchParams)
- const matchingPosts = getMatchingPosts(query)
+const useNarrowMediaQuery = makeMediaQueryStore('(max-width: 600px)')
- return (
- <ul className="post-list">
- {matchingPosts.map((post) => (
- <Card key={post.id} post={post} />
- ))}
- </ul>
- )
+function NarrowScreenNotifier() {
+ const isNarrow = useNarrowMediaQuery()
+ return isNarrow ? 'You are on a narrow screen' : 'You are on a wide screen'
}
-function Card({ post }: { post: BlogPost }) {
- const [isFavorited, setIsFavorited] = useState(false)
+function App() {
return (
- <li>
- {isFavorited ? (
- <button
- aria-label="Remove favorite"
- onClick={() => setIsFavorited(false)}
- >
- ❤️
- </button>
- ) : (
- <button aria-label="Add favorite" onClick={() => setIsFavorited(true)}>
- 🤍
- </button>
- )}
- <div
- className="post-image"
- style={{ background: generateGradient(post.id) }}
- />
- <a
- href={post.id}
- onClick={(event) => {
- event.preventDefault()
- alert(`Great! Let's go to ${post.id}!`)
- }}
- >
- <h2>{post.title}</h2>
- <p>{post.description}</p>
- </a>
- </li>
+ <div>
+ <div>This is your narrow screen state:</div>
+ <Suspense fallback="">
+ <NarrowScreenNotifier />
+ </Suspense>
+ </div>
)
}
const rootEl = document.createElement('div')
document.body.append(rootEl)
-ReactDOM.createRoot(rootEl).render(<App />)
+// 🦉 here's how we pretend we're server-rendering
+rootEl.innerHTML = (await import('react-dom/server')).renderToString(<App />)
+
+// 🦉 here's how we simulate a delay in hydrating with client-side js
+await new Promise((resolve) => setTimeout(resolve, 1000))
+
+ReactDOM.hydrateRoot(rootEl, <App />, {
+ onRecoverableError(error) {
+ if (String(error).includes('Missing getServerSnapshot')) return
+
+ console.error(error)
+ },
+})
The result is:
{
type: 'GitDiff',
files: [
{
type: 'DeletedFile',
chunks: [Array],
path: 'var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/epicshop/diff/advanced-react-apis/playground/dn2ncwjsbmo/index.css'
},
{
type: 'AddedFile',
chunks: [Array],
path: 'var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/epicshop/diff/advanced-react-apis/09.03.solution/dn2ncwjsbmo/index.tsx'
}
]
}
If I remove the empty file, then the diff is:
diff --git var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/epicshop/diff/advanced-react-apis/playground/is245u03h1s/index.tsx var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/epicshop/diff/advanced-react-apis/09.03.solution/is245u03h1s/index.tsx
index 4d68325..fd576f7 100644
--- var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/epicshop/diff/advanced-react-apis/playground/is245u03h1s/index.tsx
+++ var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/epicshop/diff/advanced-react-apis/09.03.solution/is245u03h1s/index.tsx
@@ -1,190 +1,54 @@
-import { createContext, useEffect, useState, use, useCallback } from 'react'
+import { Suspense, useSyncExternalStore } from 'react'
import * as ReactDOM from 'react-dom/client'
-import {
- type BlogPost,
- generateGradient,
- getMatchingPosts,
-} from '#shared/blog-posts'
-import { setGlobalSearchParams } from '#shared/utils'
-type SearchParamsTuple = readonly [
- URLSearchParams,
- typeof setGlobalSearchParams,
-]
-const SearchParamsContext = createContext<SearchParamsTuple>([
- new URLSearchParams(window.location.search),
- setGlobalSearchParams,
-])
-
-function SearchParamsProvider({ children }: { children: React.ReactNode }) {
- const [searchParams, setSearchParamsState] = useState(
- () => new URLSearchParams(window.location.search),
- )
+export function makeMediaQueryStore(mediaQuery: string) {
+ function getSnapshot() {
+ return window.matchMedia(mediaQuery).matches
+ }
- useEffect(() => {
- function updateSearchParams() {
- setSearchParamsState((prevParams) => {
- const newParams = new URLSearchParams(window.location.search)
- return prevParams.toString() === newParams.toString()
- ? prevParams
- : newParams
- })
+ function subscribe(callback: () => void) {
+ const mediaQueryList = window.matchMedia(mediaQuery)
+ mediaQueryList.addEventListener('change', callback)
+ return () => {
+ mediaQueryList.removeEventListener('change', callback)
}
- window.addEventListener('popstate', updateSearchParams)
- return () => window.removeEventListener('popstate', updateSearchParams)
- }, [])
-
- const setSearchParams = useCallback(
- (...args: Parameters<typeof setGlobalSearchParams>) => {
- const searchParams = setGlobalSearchParams(...args)
- setSearchParamsState((prevParams) => {
- return prevParams.toString() === searchParams.toString()
- ? prevParams
- : searchParams
- })
- return searchParams
- },
- [],
- )
-
- const searchParamsTuple = [searchParams, setSearchParams] as const
-
- return (
- <SearchParamsContext value={searchParamsTuple}>
- {children}
- </SearchParamsContext>
- )
-}
-
-function useSearchParams() {
- return use(SearchParamsContext)
-}
-
-const getQueryParam = (params: URLSearchParams) => params.get('query') ?? ''
-
-function App() {
- return (
- <SearchParamsProvider>
- <div className="app">
- <Form />
- <MatchingPosts />
- </div>
- </SearchParamsProvider>
- )
-}
-
-function Form() {
- const [searchParams, setSearchParams] = useSearchParams()
- const query = getQueryParam(searchParams)
-
- const words = query.split(' ').map((w) => w.trim())
-
- const dogChecked = words.includes('dog')
- const catChecked = words.includes('cat')
- const caterpillarChecked = words.includes('caterpillar')
-
- function handleCheck(tag: string, checked: boolean) {
- const newWords = checked ? [...words, tag] : words.filter((w) => w !== tag)
- setSearchParams(
- { query: newWords.filter(Boolean).join(' ').trim() },
- { replace: true },
- )
}
- return (
- <form onSubmit={(e) => e.preventDefault()}>
- <div>
- <label htmlFor="searchInput">Search:</label>
- <input
- id="searchInput"
- name="query"
- type="search"
- value={query}
- onChange={(e) =>
- setSearchParams({ query: e.currentTarget.value }, { replace: true })
- }
- />
- </div>
- <div>
- <label>
- <input
- type="checkbox"
- checked={dogChecked}
- onChange={(e) => handleCheck('dog', e.currentTarget.checked)}
- />{' '}
- 🐶 dog
- </label>
- <label>
- <input
- type="checkbox"
- checked={catChecked}
- onChange={(e) => handleCheck('cat', e.currentTarget.checked)}
- />{' '}
- 🐱 cat
- </label>
- <label>
- <input
- type="checkbox"
- checked={caterpillarChecked}
- onChange={(e) =>
- handleCheck('caterpillar', e.currentTarget.checked)
- }
- />{' '}
- 🐛 caterpillar
- </label>
- </div>
- </form>
- )
+ return function useMediaQuery() {
+ return useSyncExternalStore(subscribe, getSnapshot)
+ }
}
-function MatchingPosts() {
- const [searchParams] = useSearchParams()
- const query = getQueryParam(searchParams)
- const matchingPosts = getMatchingPosts(query)
+const useNarrowMediaQuery = makeMediaQueryStore('(max-width: 600px)')
- return (
- <ul className="post-list">
- {matchingPosts.map((post) => (
- <Card key={post.id} post={post} />
- ))}
- </ul>
- )
+function NarrowScreenNotifier() {
+ const isNarrow = useNarrowMediaQuery()
+ return isNarrow ? 'You are on a narrow screen' : 'You are on a wide screen'
}
-function Card({ post }: { post: BlogPost }) {
- const [isFavorited, setIsFavorited] = useState(false)
+function App() {
return (
- <li>
- {isFavorited ? (
- <button
- aria-label="Remove favorite"
- onClick={() => setIsFavorited(false)}
- >
- ❤️
- </button>
- ) : (
- <button aria-label="Add favorite" onClick={() => setIsFavorited(true)}>
- 🤍
- </button>
- )}
- <div
- className="post-image"
- style={{ background: generateGradient(post.id) }}
- />
- <a
- href={post.id}
- onClick={(event) => {
- event.preventDefault()
- alert(`Great! Let's go to ${post.id}!`)
- }}
- >
- <h2>{post.title}</h2>
- <p>{post.description}</p>
- </a>
- </li>
+ <div>
+ <div>This is your narrow screen state:</div>
+ <Suspense fallback="">
+ <NarrowScreenNotifier />
+ </Suspense>
+ </div>
)
}
const rootEl = document.createElement('div')
document.body.append(rootEl)
-ReactDOM.createRoot(rootEl).render(<App />)
+// 🦉 here's how we pretend we're server-rendering
+rootEl.innerHTML = (await import('react-dom/server')).renderToString(<App />)
+
+// 🦉 here's how we simulate a delay in hydrating with client-side js
+await new Promise((resolve) => setTimeout(resolve, 1000))
+
+ReactDOM.hydrateRoot(rootEl, <App />, {
+ onRecoverableError(error) {
+ if (String(error).includes('Missing getServerSnapshot')) return
+
+ console.error(error)
+ },
+})
And the parsed result is as expected:
{
type: 'GitDiff',
files: [
{
type: 'DeletedFile',
chunks: [Array],
path: 'var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/epicshop/diff/advanced-react-apis/playground/is245u03h1s/index.css'
},
{
type: 'ChangedFile',
chunks: [Array],
path: 'var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/epicshop/diff/advanced-react-apis/09.03.solution/is245u03h1s/index.tsx'
}
]
}
Here's a test case:
I'm not certain why the
index.test.tsx
file is causing an issue here, but if I remove that from the diff the rest parses just fine.