-
-
Notifications
You must be signed in to change notification settings - Fork 120
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: expose hash from useRouter
#746
Merged
Merged
Changes from 22 commits
Commits
Show all changes
24 commits
Select commit
Hold shift + click to select a range
a4965b6
test: test case for basic `useRouter` usage
pmelab 82365c5
test: extend test cases for hash retrieval
pmelab a9d41ec
feat: extend `useRouter` to also emit the current hash
pmelab 6fc4cac
chore: lockfile update
pmelab bde826b
refactor: make hash mandatory
pmelab e549b4a
fix: add empty hashes where required
pmelab 82b7ae7
fix: add href as effect dependency
pmelab fb2e8a1
fix: rename fixture package
pmelab 54eec05
refactor: add parameter to parseRoute to ignore the hash
pmelab e4e696e
Merge branch 'main' into use-router-hash
pmelab 46a4cae
chore: lockfile update
pmelab 96a1e4d
fix: ignore eslint warning
pmelab 7822ba5
chore: upgrade react version of fixture
pmelab ddb4086
test: remove redundant parsing of URLSearchparams to verify it breaks
pmelab 447a150
Merge branch 'main' into use-router-hash
pmelab efe18a8
fix: removed searchParams that was left over in merge
pmelab 7928ab7
fix: more merge errors
pmelab 4823571
fix: adapt test case
pmelab c0d1d24
Update packages/waku/src/router/client.ts
pmelab a21b8fb
fix: variable naming
pmelab 2369845
docs: add comment on route initialization
pmelab bafbc17
docs: typo
pmelab b6638ee
avoid dual rendering if hash===""
dai-shi 5c5c64c
Merge branch 'main' into use-router-hash
dai-shi File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,22 @@ | ||
{ | ||
"name": "use-router", | ||
"version": "0.1.0", | ||
"type": "module", | ||
"private": true, | ||
"scripts": { | ||
"dev": "waku dev", | ||
"build": "waku build", | ||
"start": "waku start" | ||
}, | ||
"dependencies": { | ||
"react": "19.0.0-rc-df5f2736-20240712", | ||
"react-dom": "19.0.0-rc-df5f2736-20240712", | ||
"react-server-dom-webpack": "19.0.0-rc-df5f2736-20240712", | ||
"waku": "workspace:*" | ||
}, | ||
"devDependencies": { | ||
"@types/react": "18.3.3", | ||
"@types/react-dom": "18.3.0", | ||
"typescript": "5.5.3" | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,33 @@ | ||
'use client'; | ||
|
||
import { Link, useRouter_UNSTABLE } from 'waku'; | ||
|
||
export default function TestRouter() { | ||
const router = useRouter_UNSTABLE(); | ||
const params = new URLSearchParams(router.query); | ||
const queryCount = parseInt(params.get('count') || '0'); | ||
const hashCount = parseInt(router.hash?.substr(1) || '0'); | ||
return ( | ||
<> | ||
<p data-testid="path">Path: {router.path}</p> | ||
<p data-testid="query">Query: {queryCount}</p> | ||
<p data-testid="hash">Hash: {hashCount}</p> | ||
<p> | ||
<Link to={`?count=${queryCount + 1}`}>Increment query</Link> | ||
</p> | ||
<p> | ||
<button onClick={() => router.push(`?count=${queryCount + 1}`)}> | ||
Increment query (push) | ||
</button> | ||
</p> | ||
<p> | ||
<Link to={`#${hashCount + 1}`}>Increment hash</Link> | ||
</p> | ||
<p> | ||
<button onClick={() => router.push(`#${hashCount + 1}`)}> | ||
Increment hash (push) | ||
</button> | ||
</p> | ||
</> | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,15 @@ | ||
import { StrictMode } from 'react'; | ||
import { createRoot, hydrateRoot } from 'react-dom/client'; | ||
import { Router } from 'waku/router/client'; | ||
|
||
const rootElement = ( | ||
<StrictMode> | ||
<Router /> | ||
</StrictMode> | ||
); | ||
|
||
if (document.body.dataset.hydrate) { | ||
hydrateRoot(document.body, rootElement); | ||
} else { | ||
createRoot(document.body).render(rootElement); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,14 @@ | ||
import { Link } from 'waku'; | ||
import TestRouter from '../TestRouter.js'; | ||
|
||
const Page = () => ( | ||
<> | ||
<h1>Dynamic</h1> | ||
<p> | ||
<Link to="/static">Go to static</Link> | ||
</p> | ||
<TestRouter /> | ||
</> | ||
); | ||
|
||
export default Page; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,20 @@ | ||
import { Link } from 'waku'; | ||
import TestRouter from '../TestRouter.js'; | ||
|
||
const Page = () => ( | ||
<> | ||
<h1>Static</h1> | ||
<p> | ||
<Link to="/dynamic">Go to dynamic</Link> | ||
</p> | ||
<TestRouter /> | ||
</> | ||
); | ||
|
||
export const getConfig = async () => { | ||
return { | ||
render: 'static', | ||
}; | ||
}; | ||
|
||
export default Page; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,17 @@ | ||
{ | ||
"compilerOptions": { | ||
"composite": true, | ||
"strict": true, | ||
"target": "esnext", | ||
"downlevelIteration": true, | ||
"esModuleInterop": true, | ||
"module": "nodenext", | ||
"skipLibCheck": true, | ||
"noUncheckedIndexedAccess": true, | ||
"exactOptionalPropertyTypes": true, | ||
"types": ["react/experimental"], | ||
"jsx": "react-jsx", | ||
"rootDir": "./src", | ||
"outDir": "./dist" | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,178 @@ | ||
import { debugChildProcess, getFreePort, terminate, test } from './utils.js'; | ||
import { fileURLToPath } from 'node:url'; | ||
import { cp, mkdtemp } from 'node:fs/promises'; | ||
import { exec, execSync } from 'node:child_process'; | ||
import { expect } from '@playwright/test'; | ||
import waitPort from 'wait-port'; | ||
import { join } from 'node:path'; | ||
import { tmpdir } from 'node:os'; | ||
import { createRequire } from 'node:module'; | ||
|
||
let standaloneDir: string; | ||
const exampleDir = fileURLToPath( | ||
new URL('./fixtures/use-router', import.meta.url), | ||
); | ||
const wakuDir = fileURLToPath(new URL('../packages/waku', import.meta.url)); | ||
const { version } = createRequire(import.meta.url)( | ||
join(wakuDir, 'package.json'), | ||
); | ||
|
||
async function start() { | ||
const port = await getFreePort(); | ||
const cp = exec( | ||
`node ${join(standaloneDir, './node_modules/waku/dist/cli.js')} start --port ${port}`, | ||
{ cwd: standaloneDir }, | ||
); | ||
debugChildProcess(cp, fileURLToPath(import.meta.url), [ | ||
/ExperimentalWarning: Custom ESM Loaders is an experimental feature and might change at any time/, | ||
]); | ||
|
||
await waitPort({ port }); | ||
return [port, cp.pid]; | ||
} | ||
|
||
test.describe('useRouter', async () => { | ||
test.beforeEach(async () => { | ||
// GitHub Action on Windows doesn't support mkdtemp on global temp dir, | ||
// Which will cause files in `src` folder to be empty. | ||
// I don't know why | ||
const tmpDir = process.env.TEMP_DIR ? process.env.TEMP_DIR : tmpdir(); | ||
standaloneDir = await mkdtemp(join(tmpDir, 'waku-use-router-')); | ||
await cp(exampleDir, standaloneDir, { | ||
filter: (src) => { | ||
return !src.includes('node_modules') && !src.includes('dist'); | ||
}, | ||
recursive: true, | ||
}); | ||
execSync(`pnpm pack --pack-destination ${standaloneDir}`, { | ||
cwd: wakuDir, | ||
stdio: 'inherit', | ||
}); | ||
const name = `waku-${version}.tgz`; | ||
execSync(`npm install ${join(standaloneDir, name)}`, { | ||
cwd: standaloneDir, | ||
stdio: 'inherit', | ||
}); | ||
execSync( | ||
`node ${join(standaloneDir, './node_modules/waku/dist/cli.js')} build`, | ||
{ | ||
cwd: standaloneDir, | ||
stdio: 'inherit', | ||
}, | ||
); | ||
}); | ||
|
||
test.describe('returns the current path', () => { | ||
test(`on dynamic pages`, async ({ page }) => { | ||
const [port, pid] = await start(); | ||
await page.goto(`http://localhost:${port}/dynamic`); | ||
await expect( | ||
page.getByRole('heading', { name: 'Dynamic' }), | ||
).toBeVisible(); | ||
await expect(page.getByTestId('path')).toHaveText('Path: /dynamic'); | ||
|
||
await terminate(pid!); | ||
}); | ||
test(`on static pages`, async ({ page }) => { | ||
const [port, pid] = await start(); | ||
await page.goto(`http://localhost:${port}/static`); | ||
await expect(page.getByRole('heading', { name: 'Static' })).toBeVisible(); | ||
await expect(page.getByTestId('path')).toHaveText('Path: /static'); | ||
|
||
await terminate(pid!); | ||
}); | ||
}); | ||
|
||
test.describe('updates path on link navigation', () => { | ||
test(`on dynamic pages`, async ({ page }) => { | ||
const [port, pid] = await start(); | ||
await page.goto(`http://localhost:${port}/dynamic`); | ||
await page.click('text=Go to static'); | ||
await expect(page.getByRole('heading', { name: 'Static' })).toBeVisible(); | ||
await expect(page.getByTestId('path')).toHaveText('Path: /static'); | ||
await terminate(pid!); | ||
}); | ||
test(`on static pages`, async ({ page }) => { | ||
const [port, pid] = await start(); | ||
await page.goto(`http://localhost:${port}/static`); | ||
await page.click('text=Go to dynamic'); | ||
await expect( | ||
page.getByRole('heading', { name: 'Dynamic' }), | ||
).toBeVisible(); | ||
await expect(page.getByTestId('path')).toHaveText('Path: /dynamic'); | ||
await terminate(pid!); | ||
}); | ||
}); | ||
|
||
test.describe('retrieves query variables', () => { | ||
test(`on dynamic pages`, async ({ page }) => { | ||
const [port, pid] = await start(); | ||
await page.goto(`http://localhost:${port}/dynamic?count=42`); | ||
await expect(page.getByTestId('query')).toHaveText('Query: 42'); | ||
await terminate(pid!); | ||
}); | ||
test(`on static pages`, async ({ page }) => { | ||
const [port, pid] = await start(); | ||
await page.goto(`http://localhost:${port}/static?count=42`); | ||
await expect(page.getByTestId('query')).toHaveText('Query: 42'); | ||
await terminate(pid!); | ||
}); | ||
}); | ||
|
||
test.describe('updates query variables', () => { | ||
test(`on dynamic pages`, async ({ page }) => { | ||
const [port, pid] = await start(); | ||
await page.goto(`http://localhost:${port}/dynamic`); | ||
await page.click('text=Increment query'); | ||
await expect(page.getByTestId('query')).toHaveText('Query: 1'); | ||
await page.click('text=Increment query (push)'); | ||
await expect(page.getByTestId('query')).toHaveText('Query: 2'); | ||
await terminate(pid!); | ||
}); | ||
test(`on static pages`, async ({ page }) => { | ||
const [port, pid] = await start(); | ||
await page.goto(`http://localhost:${port}/static`); | ||
await page.click('text=Increment query'); | ||
await expect(page.getByTestId('query')).toHaveText('Query: 1'); | ||
await page.click('text=Increment query (push)'); | ||
await expect(page.getByTestId('query')).toHaveText('Query: 2'); | ||
await terminate(pid!); | ||
}); | ||
}); | ||
|
||
test.describe('retrieves hashes', () => { | ||
test(`on dynamic pages`, async ({ page }) => { | ||
const [port, pid] = await start(); | ||
await page.goto(`http://localhost:${port}/dynamic#42`); | ||
await expect(page.getByTestId('hash')).toHaveText('Hash: 42'); | ||
await terminate(pid!); | ||
}); | ||
test(`on static pages`, async ({ page }) => { | ||
const [port, pid] = await start(); | ||
await page.goto(`http://localhost:${port}/static#42`); | ||
await expect(page.getByTestId('hash')).toHaveText('Hash: 42'); | ||
await terminate(pid!); | ||
}); | ||
}); | ||
|
||
test.describe('updates hashes', () => { | ||
test(`on dynamic pages`, async ({ page }) => { | ||
const [port, pid] = await start(); | ||
await page.goto(`http://localhost:${port}/dynamic`); | ||
await page.click('text=Increment hash'); | ||
await expect(page.getByTestId('hash')).toHaveText('Hash: 1'); | ||
await page.click('text=Increment hash (push)'); | ||
await expect(page.getByTestId('hash')).toHaveText('Hash: 2'); | ||
await terminate(pid!); | ||
}); | ||
test(`on static pages`, async ({ page }) => { | ||
const [port, pid] = await start(); | ||
await page.goto(`http://localhost:${port}/static`); | ||
await page.click('text=Increment hash'); | ||
await expect(page.getByTestId('hash')).toHaveText('Hash: 1'); | ||
await page.click('text=Increment hash (push)'); | ||
await expect(page.getByTestId('hash')).toHaveText('Hash: 2'); | ||
await terminate(pid!); | ||
}); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -48,15 +48,19 @@ const normalizeRoutePath = (path: string) => { | |
return path; | ||
}; | ||
|
||
const parseRoute = (url: URL): RouteProps => { | ||
const parseRoute = (url: URL, ignoreHash = false): RouteProps => { | ||
if ((globalThis as any).__WAKU_ROUTER_404__) { | ||
return { path: '/404', query: '' }; | ||
return { path: '/404', query: '', hash: '' }; | ||
} | ||
const { pathname, searchParams } = url; | ||
if (searchParams.has(PARAM_KEY_SKIP)) { | ||
console.warn(`The search param "${PARAM_KEY_SKIP}" is reserved`); | ||
} | ||
return { path: normalizeRoutePath(pathname), query: searchParams.toString() }; | ||
return { | ||
path: normalizeRoutePath(pathname), | ||
query: searchParams.toString(), | ||
hash: ignoreHash ? '' : url.hash, | ||
}; | ||
}; | ||
|
||
type ChangeRoute = ( | ||
|
@@ -301,8 +305,19 @@ const InnerRouter = ({ routerData }: { routerData: RouterData }) => { | |
const refetch = useRefetch(); | ||
|
||
const [route, setRoute] = useState(() => | ||
parseRoute(new URL(window.location.href)), | ||
// This is the first initialization of the route, and it has | ||
// to ignore the hash, because on server side there is none. | ||
// Otherwise there will be a hydration error. | ||
// The client side route, including the hash, will be updated in the effect below. | ||
parseRoute(new URL(window.location.href), true), | ||
); | ||
|
||
// Update the route post-load to include the current hash. | ||
useEffect(() => { | ||
setRoute(parseRoute(new URL(window.location.href))); | ||
dai-shi marked this conversation as resolved.
Show resolved
Hide resolved
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This actually triggers re-renders every time, even if the hash is |
||
// eslint-disable-next-line react-hooks/exhaustive-deps | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hm, I wonder why we need the exception. |
||
}, [window.location.href]); | ||
|
||
const componentIds = getComponentIds(route.path); | ||
|
||
const [cached, setCached] = useState<Record<string, RouteProps>>(() => { | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So,
ignoreHash
is used only here.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, without this, there are hydration errors, because the server has no hash, but the first client render does.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh, I see. Can you leave some comments in the code?
I might refactor things around here in the future.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I added some explanations there