Skip to content
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 24 commits into from
Jul 25, 2024
Merged
Show file tree
Hide file tree
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 Jun 13, 2024
82365c5
test: extend test cases for hash retrieval
pmelab Jun 13, 2024
a9d41ec
feat: extend `useRouter` to also emit the current hash
pmelab Jun 13, 2024
6fc4cac
chore: lockfile update
pmelab Jun 13, 2024
bde826b
refactor: make hash mandatory
pmelab Jun 14, 2024
e549b4a
fix: add empty hashes where required
pmelab Jun 14, 2024
82b7ae7
fix: add href as effect dependency
pmelab Jul 16, 2024
fb2e8a1
fix: rename fixture package
pmelab Jul 16, 2024
54eec05
refactor: add parameter to parseRoute to ignore the hash
pmelab Jul 16, 2024
e4e696e
Merge branch 'main' into use-router-hash
pmelab Jul 16, 2024
46a4cae
chore: lockfile update
pmelab Jul 16, 2024
96a1e4d
fix: ignore eslint warning
pmelab Jul 16, 2024
7822ba5
chore: upgrade react version of fixture
pmelab Jul 16, 2024
ddb4086
test: remove redundant parsing of URLSearchparams to verify it breaks
pmelab Jul 16, 2024
447a150
Merge branch 'main' into use-router-hash
pmelab Jul 25, 2024
efe18a8
fix: removed searchParams that was left over in merge
pmelab Jul 25, 2024
7928ab7
fix: more merge errors
pmelab Jul 25, 2024
4823571
fix: adapt test case
pmelab Jul 25, 2024
c0d1d24
Update packages/waku/src/router/client.ts
pmelab Jul 25, 2024
a21b8fb
fix: variable naming
pmelab Jul 25, 2024
2369845
docs: add comment on route initialization
pmelab Jul 25, 2024
bafbc17
docs: typo
pmelab Jul 25, 2024
b6638ee
avoid dual rendering if hash===""
dai-shi Jul 25, 2024
5c5c64c
Merge branch 'main' into use-router-hash
dai-shi Jul 25, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions e2e/fixtures/use-router/package.json
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"
}
}
33 changes: 33 additions & 0 deletions e2e/fixtures/use-router/src/TestRouter.tsx
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>
</>
);
}
15 changes: 15 additions & 0 deletions e2e/fixtures/use-router/src/main.tsx
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);
}
14 changes: 14 additions & 0 deletions e2e/fixtures/use-router/src/pages/dynamic.tsx
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;
20 changes: 20 additions & 0 deletions e2e/fixtures/use-router/src/pages/static.tsx
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;
17 changes: 17 additions & 0 deletions e2e/fixtures/use-router/tsconfig.json
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"
}
}
178 changes: 178 additions & 0 deletions e2e/use-router.spec.ts
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!);
});
});
});
23 changes: 19 additions & 4 deletions packages/waku/src/router/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand Down Expand Up @@ -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),
Copy link
Owner

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.

Copy link
Contributor Author

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.

Copy link
Owner

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.

Copy link
Contributor Author

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

);

// 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
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This actually triggers re-renders every time, even if the hash is ''. We should probably improve it.

// eslint-disable-next-line react-hooks/exhaustive-deps
Copy link
Owner

Choose a reason for hiding this comment

The 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>>(() => {
Expand Down
1 change: 1 addition & 0 deletions packages/waku/src/router/common.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export type RouteProps = {
path: string;
query: string;
hash: string;
};

export function getComponentIds(path: string): readonly string[] {
Expand Down
2 changes: 1 addition & 1 deletion packages/waku/src/router/define-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,7 @@ globalThis.__WAKU_ROUTER_PREFETCH__ = (path) => {
ServerRouter as FunctionComponent<
Omit<ComponentProps<typeof ServerRouter>, 'children'>
>,
{ route: { path: pathname, query: searchParams.toString() } },
{ route: { path: pathname, query: searchParams.toString(), hash: '' } },
componentIds.reduceRight(
(acc: ReactNode, id) => createElement(Slot, { id, fallback: acc }, acc),
null,
Expand Down
Loading
Loading