diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index 0acf99f..0000000 --- a/.eslintignore +++ /dev/null @@ -1,3 +0,0 @@ -dist -node_modules -build diff --git a/.eslintrc.cjs b/.eslintrc.cjs deleted file mode 100644 index 9c9f752..0000000 --- a/.eslintrc.cjs +++ /dev/null @@ -1,18 +0,0 @@ -module.exports = { - root: true, - extends: ['@block65/eslint-config', '@block65/eslint-config/react'], - parserOptions: { - tsconfigRootDir: __dirname, - project: './tsconfig.json', - }, - - overrides: [ - { - files: ['src/examples/**/*', '__tests__/**/*.tsx', '*.config.ts'], - rules: { - // allow extraneous DEV deps - 'import/no-extraneous-dependencies': 'off', - }, - }, - ], -}; diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 4afb6a6..bf954cf 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,26 +1,28 @@ name: Deploy on: - workflow_dispatch: + workflow_dispatch: {} release: types: [published] jobs: publish-npm: runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + attestations: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@v5 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v6 with: + node-version: 24 + registry-url: https://registry.npmjs.org/ cache: 'pnpm' - registry-url: 'https://registry.npmjs.org' - node-version-file: .node-version - run: make - - run: pnpm publish --access=public --no-git-checks - env: - NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} + - run: pnpm publish --access=public --no-git-checks --provenance diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 94feb50..048d0a3 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -6,16 +6,21 @@ on: jobs: test: - runs-on: ubuntu-latest + strategy: + matrix: + version: [24] + os: [ubuntu-latest] + + runs-on: ${{ matrix.os }} + steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@v5 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v6 with: - node-version-file: .node-version + node-version: ${{ matrix.version }} cache: 'pnpm' - registry-url: 'https://registry.npmjs.org' - run: make test diff --git a/.node-version b/.node-version deleted file mode 100644 index 209e3ef..0000000 --- a/.node-version +++ /dev/null @@ -1 +0,0 @@ -20 diff --git a/.oxfmtrc.json b/.oxfmtrc.json new file mode 100644 index 0000000..3b30cbd --- /dev/null +++ b/.oxfmtrc.json @@ -0,0 +1,13 @@ +{ + "arrowParens": "always", + "bracketSpacing": true, + "jsxSingleQuote": false, + "printWidth": 80, + "singleQuote": true, + "trailingComma": "all", + "endOfLine": "lf", + "useTabs": false, + "semi": true, + "sortPackageJson": true, + "ignorePatterns": ["pnpm-lock.yaml", "node_modules", "dist", "build"], +} diff --git a/.prettierignore b/.prettierignore deleted file mode 100644 index dc8f99f..0000000 --- a/.prettierignore +++ /dev/null @@ -1,4 +0,0 @@ -pnpm-lock.yaml -node_modules -dist -build diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..1bcde7d --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "biome.enabled": false, + "prettier.enable": false, + "eslint.enable": false, + "oxc.enable": true, + "typescript.tsdk": "node_modules/typescript/lib", +} diff --git a/CREDITS.md b/CREDITS.md index 40cf466..8baa398 100644 --- a/CREDITS.md +++ b/CREDITS.md @@ -1,4 +1,5 @@ -Credit to [@nikparo](https://github.com/nikparo) for "Running React parent effects before child effects" +Credit to [@nikparo](https://github.com/nikparo) for "Running React parent +effects before child effects" ``` // Sometimes you want to run parent effects before those of the children. E.g. when setting diff --git a/Makefile b/Makefile index 6ddade1..1198730 100644 --- a/Makefile +++ b/Makefile @@ -1,12 +1,10 @@ -SRCS := $(shell find lib -print -name *.ts?) - -all: build/main.js types +.PHONY: all +all: test .PHONY: clean clean: pnpm tsc -b --clean - rm -rf dist build .PHONY: distclean distclean: clean @@ -15,8 +13,7 @@ distclean: clean .PHONY: test test: node_modules pnpm tsc --noEmit - pnpm vitest run - $(MAKE) build/main.js + pnpm vitest run --typecheck .PRECIOUS: pnpm-lock.yaml node_modules: pnpm-lock.yaml package.json @@ -24,17 +21,9 @@ node_modules: pnpm-lock.yaml package.json .PHONY: dev dev: node_modules - pnpm vite dev - -.PHONY: types -types: node_modules - pnpm tsc - -build/main.js: node_modules $(SRCS) bundlesize.config.cjs - NODE_ENV=production pnpm vite build - npx bundlesize + pnpm vite dev --config examples/vite.config.ts examples .PHONY: pretty pretty: - pnpm eslint --fix . - pnpm prettier --write . \ No newline at end of file + pnpm oxlint --fix . + pnpm oxfmt --write . diff --git a/__tests__/components/NavigationInsideEffect.tsx b/__tests__/components/NavigationInsideEffect.tsx index 26ff1a7..cddf98a 100644 --- a/__tests__/components/NavigationInsideEffect.tsx +++ b/__tests__/components/NavigationInsideEffect.tsx @@ -1,5 +1,5 @@ import { type FC, useEffect } from 'react'; -import { useNavigate } from '../../src/index.js'; +import { useNavigate } from '@block65/mrr'; import { LocationDisplay } from '../main.test.js'; export const NavigationInsideEffect: FC = () => { diff --git a/__tests__/hooks.test.tsx b/__tests__/hooks.test.tsx index ea119b8..00b845d 100644 --- a/__tests__/hooks.test.tsx +++ b/__tests__/hooks.test.tsx @@ -2,12 +2,11 @@ import '@testing-library/jest-dom'; import { render } from '@testing-library/react'; import { expect, test } from 'vitest'; import type { ExtractRouteParams } from '../lib/types.js'; -import { Route, Router, Routes, useRouteParams } from '../src/index.js'; -import { namedRoute } from '../src/named-route.js'; +import { Route, Router, Routes, useRouteParams } from '@block65/mrr'; +import { namedRoute } from '@block65/mrr/named-route'; const login = namedRoute('/foo/:foo/bar/:bar?'); -// eslint-disable-next-line @typescript-eslint/no-unused-vars const EffCee = (_: ExtractRouteParams) => { const match = useRouteParams(); return
{JSON.stringify(match, null, 2)}
; diff --git a/__tests__/link.test.tsx b/__tests__/link.test.tsx index 247fdcb..21ee6d0 100644 --- a/__tests__/link.test.tsx +++ b/__tests__/link.test.tsx @@ -3,8 +3,8 @@ import { render } from '@testing-library/react'; import type { AnchorHTMLAttributes, FC, PropsWithChildren } from 'react'; import { FormattedMessage, IntlProvider } from 'react-intl'; import { expect, test } from 'vitest'; -import { Link, Router } from '../src/index.js'; -import { namedRoute } from '../src/named-route.js'; +import { Link, Router } from '@block65/mrr'; +import { namedRoute } from '@block65/mrr/named-route'; const ComponentThatTakesProps: FC< PropsWithChildren> diff --git a/__tests__/main.test.tsx b/__tests__/main.test.tsx index fe0a0e6..69b6db0 100644 --- a/__tests__/main.test.tsx +++ b/__tests__/main.test.tsx @@ -1,8 +1,8 @@ import '@testing-library/jest-dom'; import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { expect, test } from 'vitest'; -import { Link, Route, Router, Routes, useLocation } from '../src/index.js'; -import { namedRoute } from '../src/named-route.js'; +import { Link, Route, Router, Routes, useLocation } from '@block65/mrr'; +import { namedRoute } from '@block65/mrr/named-route'; export const LocationDisplay = () => { const [location] = useLocation(); diff --git a/__tests__/named-route.test-d.ts b/__tests__/named-route.test-d.ts new file mode 100644 index 0000000..3eba33d --- /dev/null +++ b/__tests__/named-route.test-d.ts @@ -0,0 +1,63 @@ +import { assertType, expectTypeOf, test } from 'vitest'; +import { namedRoute } from '@block65/mrr/named-route'; + +test('paramless route - build is optional', () => { + const route = namedRoute('/'); + assertType(route.build()); + assertType(route.build({ hash: '#foo' })); +}); + +test('parameterised route - params required', () => { + const route = namedRoute('/user/:id'); + assertType(route.build({ params: { id: '1' } })); + + // @ts-expect-error - params is required + route.build(); + // @ts-expect-error - params is required + route.build({}); + // @ts-expect-error - id is required + route.build({ params: {} }); +}); + +test('searchParams() narrows searchParams type', () => { + const route = namedRoute('/search').searchParams<{ q: string }>(); + + assertType(route.build({ searchParams: { q: 'hello' } })); + + // @ts-expect-error - wrong key + route.build({ searchParams: { wrong: 'nope' } }); + // @ts-expect-error - missing required key + route.build({ searchParams: {} }); +}); + +test('searchParams() with path params', () => { + const route = namedRoute('/user/:id').searchParams<{ tab: string }>(); + + assertType( + route.build({ params: { id: '1' }, searchParams: { tab: 'posts' } }), + ); + + // @ts-expect-error - params still required + route.build({ searchParams: { tab: 'posts' } }); + // @ts-expect-error - wrong searchParams key + route.build({ params: { id: '1' }, searchParams: { wrong: 'nope' } }); +}); + +test('path type is preserved as literal', () => { + const route = namedRoute('/user/:id'); + expectTypeOf(route.path).toEqualTypeOf<'/user/:id'>(); +}); + +test('path type preserved through searchParams()', () => { + const route = namedRoute('/user/:id').searchParams<{ q: string }>(); + expectTypeOf(route.path).toEqualTypeOf<'/user/:id'>(); +}); + +test('searchParams is chainable', () => { + const route = namedRoute('/').searchParams<{ a: string }>(); + const route2 = route.searchParams<{ b: string }>(); + + assertType(route2.build({ searchParams: { b: 'yes' } })); + // @ts-expect-error - old type no longer valid + route2.build({ searchParams: { a: 'nope' } }); +}); diff --git a/__tests__/named-route.test.ts b/__tests__/named-route.test.ts index 7302cf9..9e9df52 100644 --- a/__tests__/named-route.test.ts +++ b/__tests__/named-route.test.ts @@ -1,6 +1,6 @@ import '@testing-library/jest-dom'; import { expect, test } from 'vitest'; -import { namedRoute } from '../src/named-route.js'; +import { namedRoute } from '@block65/mrr/named-route'; const route1 = namedRoute('/'); const route2 = namedRoute('/test'); diff --git a/__tests__/navigate.test.tsx b/__tests__/navigate.test.tsx index 6564f2a..3981383 100644 --- a/__tests__/navigate.test.tsx +++ b/__tests__/navigate.test.tsx @@ -1,8 +1,8 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import type { FC } from 'react'; import { expect, test } from 'vitest'; -import { Route, Router, Routes, useNavigate } from '../src/index.js'; -import { namedRoute } from '../src/named-route.js'; +import { Route, Router, Routes, useNavigate } from '@block65/mrr'; +import { namedRoute } from '@block65/mrr/named-route'; import { NavigationInsideEffect } from './components/NavigationInsideEffect.js'; import { LocationDisplay } from './main.test.js'; diff --git a/__tests__/nested.test.tsx b/__tests__/nested.test.tsx index 16a3b80..5671d70 100644 --- a/__tests__/nested.test.tsx +++ b/__tests__/nested.test.tsx @@ -2,8 +2,8 @@ import '@testing-library/jest-dom'; import { render, screen, waitFor } from '@testing-library/react'; import type { FC } from 'react'; import { expect, test } from 'vitest'; -import { namedRoute } from '../lib/named-route.js'; -import { Route, Router, Routes } from '../src/index.js'; +import { namedRoute } from '@block65/mrr/named-route'; +import { Route, Router, Routes } from '@block65/mrr'; import { LocationDisplay } from './main.test.js'; test('wildcard routes + nested', async () => { diff --git a/__tests__/route.test.tsx b/__tests__/route.test.tsx index ab7b707..ec1c04a 100644 --- a/__tests__/route.test.tsx +++ b/__tests__/route.test.tsx @@ -2,14 +2,14 @@ import '@testing-library/jest-dom'; import { render } from '@testing-library/react'; import { useCallback, useEffect, type FC } from 'react'; import { expect, test, vi } from 'vitest'; -import { namedRoute } from '../lib/named-route.js'; +import { namedRoute } from '@block65/mrr/named-route'; import type { RouteComponentProps } from '../lib/types.js'; import { Route, Router, Routes, type SyntheticNavigateEvent, -} from '../src/index.js'; +} from '@block65/mrr'; const login = namedRoute('/'); diff --git a/__tests__/type-inference.test.tsx b/__tests__/type-inference.test.tsx index 2fa76f1..64ccb78 100644 --- a/__tests__/type-inference.test.tsx +++ b/__tests__/type-inference.test.tsx @@ -2,8 +2,8 @@ import '@testing-library/jest-dom'; import { render } from '@testing-library/react'; import { expect, test } from 'vitest'; import type { ExtractRouteParams } from '../lib/types.js'; -import { Route, Router, Routes } from '../src/index.js'; -import { namedRoute } from '../src/named-route.js'; +import { Route, Router, Routes } from '@block65/mrr'; +import { namedRoute } from '@block65/mrr/named-route'; const login = namedRoute('/foo/:foo'); diff --git a/bundlesize.config.cjs b/bundlesize.config.cjs deleted file mode 100644 index e79e50c..0000000 --- a/bundlesize.config.cjs +++ /dev/null @@ -1,14 +0,0 @@ -module.exports = { - files: [ - { - path: './build/*.js', - maxSize: '4.5 kB', - compression: 'brotli', - }, - { - path: './build/main.js', - maxSize: '3.5 kB', - compression: 'brotli', - }, - ], -}; diff --git a/examples/App.tsx b/examples/App.tsx new file mode 100644 index 0000000..837d128 --- /dev/null +++ b/examples/App.tsx @@ -0,0 +1,229 @@ +import { type FC } from 'react'; +import { + Link, + Redirect, + Route, + Router, + Routes, + useLocation, +} from '@block65/mrr'; +import { Programmatic } from './Programmatic.js'; +import { UnloadDialog } from './UnloadDialog.js'; +import { UnloadWarn } from './UnloadWarn.js'; +import { ViewTransitionsExample } from './animation/ViewTransitions.js'; +import { + admin, + animationRoute, + everywhere, + here, + index, + login, + nowhere, + there, + user, +} from './paths.js'; + +const PathDisplay: FC = () => { + const [location] = useLocation(); + return ( + + {location.pathname} + {location.search} + + ); +}; + +const NavItem: FC<{ + href: string; + label: string; + tag: string; + color?: string; +}> = ({ href, label, tag, color = 'bg-accent/10 text-accent' }) => ( + + + + {label} + + + {tag} + + + → + + + +); + +const BackLink: FC = () => ( + + + ← back + + +); + +const PageFrame: FC<{ + title: string; + subtitle?: string; + children?: React.ReactNode; +}> = ({ title, subtitle, children }) => ( +
+
+ +

{title}

+ {subtitle && ( +

{subtitle}

+ )} +
+ {children} +
+); + +export const App: FC = () => ( + +
+
+ + + + m + + mrr + + + +
+ +
+ + +
+

Explore

+

+ Router feature demos +

+
+ + +
+ + + + + + <> + <> + <> + + + + + + + + + + +
+ +
+ +
+ + + + + + + + + + + + + + + + + + Authenticate → + + + + + + + +
+ + /user + + + logout + +
+
+
+ + <> + + +
+ + /admin + + + logout + +
+
+
+ + + + + + + + click if no redirect → + + + + + + + + + + ← index + + + +
+
+ + +
+
+); diff --git a/examples/Programmatic.tsx b/examples/Programmatic.tsx new file mode 100644 index 0000000..62348d6 --- /dev/null +++ b/examples/Programmatic.tsx @@ -0,0 +1,27 @@ +import { useCallback, type FC } from 'react'; +import { useNavigate } from '@block65/mrr'; + +export const Programmatic: FC = () => { + const { navigate } = useNavigate(); + + const nav = useCallback(() => { + navigate({ + searchParams: new URLSearchParams({ foo: Date.now().toString() }), + }); + }, [navigate]); + + return ( +
+ + + Appends timestamp to search params + +
+ ); +}; diff --git a/examples/UnloadDialog.tsx b/examples/UnloadDialog.tsx new file mode 100644 index 0000000..cd5c9e9 --- /dev/null +++ b/examples/UnloadDialog.tsx @@ -0,0 +1,87 @@ +import { useCallback, useEffect, useState, type FC } from 'react'; +import { useRouterIntercept } from '@block65/mrr'; + +export const UnloadDialog: FC = () => { + const [canLeave, setCanLeave] = useState(false); + const [showModal, setShowModal] = useState(false); + const [pendingResolve, setPendingResolve] = + useState<(value: boolean) => void>(); + + const intercept = useRouterIntercept(); + + useEffect( + () => + intercept(async (e, next) => { + if (!canLeave) { + const allowed = await new Promise((resolve) => { + setPendingResolve(() => resolve); + setShowModal(true); + }); + + if (!allowed) { + e.preventDefault(); + } + } + + await next(); + }), + [canLeave, intercept], + ); + + const handleResponse = useCallback( + (allowed: boolean) => { + pendingResolve?.(allowed); + setShowModal(false); + }, + [pendingResolve], + ); + + return ( +
+
+ + {canLeave ? 'Navigation allowed' : 'Navigation blocked'} +
+ + {showModal && ( + +
+

Leave this page?

+

You have unsaved state.

+
+
+ + +
+
+ )} + + +
+ ); +}; diff --git a/examples/UnloadWarn.tsx b/examples/UnloadWarn.tsx new file mode 100644 index 0000000..efda616 --- /dev/null +++ b/examples/UnloadWarn.tsx @@ -0,0 +1,42 @@ +import { useState, type FC } from 'react'; +import { usePreventUnload } from '@block65/mrr'; + +export const UnloadWarn: FC = () => { + const defaultValue = 'delicious'; + const [value, setValue] = useState(defaultValue); + + const preventUnload = value !== defaultValue; + + usePreventUnload(preventUnload); + + return ( +
+
+ + {preventUnload ? 'Browser unload prevented (refresh/close)' : 'No unload guard'} +
+ +
+ + +
+
+ ); +}; diff --git a/src/examples/animation/FramerMotion.tsx b/examples/animation/FramerMotion.tsx similarity index 61% rename from src/examples/animation/FramerMotion.tsx rename to examples/animation/FramerMotion.tsx index 1ec3cd8..9a2f889 100644 --- a/src/examples/animation/FramerMotion.tsx +++ b/examples/animation/FramerMotion.tsx @@ -1,14 +1,13 @@ -import { Heading } from '@block65/react-design-system'; import { AnimatePresence, motion } from 'framer-motion'; import type { FC, PropsWithChildren } from 'react'; -import { Routes } from '../../../lib/Routes.js'; -import { Link, Route, useLocation, type LinkProps } from '../../index.js'; +import { Routes } from '../../lib/Routes.js'; +import { Link, Route, useLocation, type LinkProps } from '@block65/mrr'; import { HSL, RGB } from './components.js'; import { hslRoute, rgbRoute } from './routes.js'; export const NavLink = (props: LinkProps) => (
  • - +
  • ); @@ -32,57 +31,30 @@ export const FramerMotionExample = () => { return (
    {location.pathname} -