From cd4ba8a1db1bff7b3e715f236d2b3531b57c484f Mon Sep 17 00:00:00 2001 From: Luca Casonato Date: Thu, 17 Dec 2020 22:08:02 +0100 Subject: [PATCH] refactor: new router (#55) This implements a new, more robust router. --- .github/workflows/ci.yml | 10 +- .vscode/settings.json | 14 ++- cli.ts | 2 - example/deps.ts | 4 +- example/pages/index.tsx | 4 +- example/tsconfig.json | 4 +- src/bundle.ts | 3 - src/runtime/default_app.tsx | 1 - src/runtime/memo.d.ts | 17 ++++ src/runtime/memo.js | 34 +++++++ src/runtime/mod.tsx | 97 +++++++++++++------ src/runtime/prerender_page_host.tsx | 2 + src/runtime/router/interceptor.ts | 10 +- src/runtime/router/location.ts | 95 +++--------------- src/runtime/router/matcher.ts | 4 +- src/runtime/tsconfig.json | 4 +- .../fixtures/dataHooks/get_static_data_let.ts | 2 +- .../custom_app_and_document/tsconfig.json | 4 +- tests/fixtures/full/simple/tsconfig.json | 4 +- .../full/static_generation/tsconfig.json | 4 +- 20 files changed, 174 insertions(+), 145 deletions(-) create mode 100644 src/runtime/memo.d.ts create mode 100644 src/runtime/memo.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index db7f43d..250dfec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,8 +13,8 @@ jobs: strategy: matrix: - deno: ["v1.x", "nightly"] - os: [macOS-latest, ubuntu-latest] # disable on windows-latest because fly tls aint working there + deno: ["v1.x", "nightly"] + os: [macOS-latest, ubuntu-latest, windows-latest] steps: - name: Disable autocrlf @@ -31,8 +31,12 @@ jobs: - name: Check formatting run: deno fmt --check + - name: Check lint + run: deno lint --unstable + - name: Install dependencies - run: deno cache --no-check ./deps/mod.ts + run: | + deno cache --no-check ./deps/mod.ts ./deps/preact/mod.ts ./deps/preact/hooks.ts ./deps/preact/debug.ts ./deps/preact/ssr.ts ./example/deps.ts - name: Run Tests run: deno test -A --unstable diff --git a/.vscode/settings.json b/.vscode/settings.json index e40716f..3448f6f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,17 @@ { "deno.enable": true, "deno.lint": true, - "deno.unstable": true + "deno.unstable": true, + "[javascript]": { + "editor.defaultFormatter": "denoland.vscode-deno" + }, + "[javascriptreact]": { + "editor.defaultFormatter": "denoland.vscode-deno" + }, + "[typescript]": { + "editor.defaultFormatter": "denoland.vscode-deno" + }, + "[typescriptreact]": { + "editor.defaultFormatter": "denoland.vscode-deno" + } } diff --git a/cli.ts b/cli.ts index b5be60f..483ea03 100644 --- a/cli.ts +++ b/cli.ts @@ -345,8 +345,6 @@ async function create(_options: unknown, maybeRoot?: string) { jsx: "react", jsxFactory: "h", jsxFragmentFactory: "Fragment", - importsNotUsedAsValues: "error", - isolatedModules: true, }, }), ); diff --git a/example/deps.ts b/example/deps.ts index b5ffa82..9646591 100644 --- a/example/deps.ts +++ b/example/deps.ts @@ -1,4 +1,6 @@ -export { Fragment, h } from "https://deno.land/x/dext@0.9.5/deps/preact/mod.ts"; +export { Fragment, h } from "../deps/preact/mod.ts"; +export type { JSX } from "../deps/preact/mod.ts"; +export { useLocation } from "../mod.ts"; export type { AppProps, DocumentProps, diff --git a/example/pages/index.tsx b/example/pages/index.tsx index c48854f..376cba0 100644 --- a/example/pages/index.tsx +++ b/example/pages/index.tsx @@ -1,4 +1,4 @@ -import { Fragment, h } from "../deps.ts"; +import { Fragment, h, useLocation } from "../deps.ts"; import type { GetStaticData, PageProps } from "../deps.ts"; interface Data { @@ -6,6 +6,8 @@ interface Data { } function IndexPage(props: PageProps) { + const [path] = useLocation(); + return ( <>

Hello World!!!

diff --git a/example/tsconfig.json b/example/tsconfig.json index fe468c1..f41d4f1 100644 --- a/example/tsconfig.json +++ b/example/tsconfig.json @@ -3,8 +3,6 @@ "lib": ["esnext", "dom", "deno.ns"], "jsx": "react", "jsxFactory": "h", - "jsxFragmentFactory": "Fragment", - "importsNotUsedAsValues": "error", - "isolatedModules": true + "jsxFragmentFactory": "Fragment" } } diff --git a/src/bundle.ts b/src/bundle.ts index acd1fb2..d959d7d 100644 --- a/src/bundle.ts +++ b/src/bundle.ts @@ -88,9 +88,6 @@ export async function bundle( output: outputOptions, preserveEntrySignatures: false, cache: options.cache, - treeshake: { - moduleSideEffects: false, - }, }; const outDir = outputOptions.dir!; diff --git a/src/runtime/default_app.tsx b/src/runtime/default_app.tsx index 4c1a8c2..369a429 100644 --- a/src/runtime/default_app.tsx +++ b/src/runtime/default_app.tsx @@ -1,4 +1,3 @@ -import { h } from "../../deps/preact/mod.ts"; import type { AppProps } from "./type.ts"; function App(props: AppProps) { diff --git a/src/runtime/memo.d.ts b/src/runtime/memo.d.ts new file mode 100644 index 0000000..61ac204 --- /dev/null +++ b/src/runtime/memo.d.ts @@ -0,0 +1,17 @@ +// deno-lint-ignore-file +import { + ComponentProps, + FunctionalComponent, + FunctionComponent, +} from "../../deps/preact/mod.ts"; +export function memo

( + component: FunctionalComponent

, + comparer?: (prev: P, next: P) => boolean, +): FunctionComponent

; +export function memo>( + component: C, + comparer?: ( + prev: ComponentProps, + next: ComponentProps, + ) => boolean, +): C; diff --git a/src/runtime/memo.js b/src/runtime/memo.js new file mode 100644 index 0000000..cd38a51 --- /dev/null +++ b/src/runtime/memo.js @@ -0,0 +1,34 @@ +/// + +import { createElement } from "../../deps/preact/mod.ts"; + +export function shallowDiffers(a, b) { + for (const i in a) if (i !== "__source" && !(i in b)) return true; + for (const i in b) if (i !== "__source" && a[i] !== b[i]) return true; + return false; +} + +export function memo(c, comparer) { + function shouldUpdate(nextProps) { + const ref = this.props.ref; + const updateRef = ref == nextProps.ref; + if (!updateRef && ref) { + ref.call ? ref(null) : (ref.current = null); + } + + if (!comparer) { + return shallowDiffers(this.props, nextProps); + } + + return !comparer(this.props, nextProps) || !updateRef; + } + + function Memoed(props) { + this.shouldComponentUpdate = shouldUpdate; + return createElement(c, props); + } + Memoed.displayName = "Memo(" + (c.displayName || c.name) + ")"; + Memoed.prototype.isReactComponent = true; + Memoed._forwarded = true; + return Memoed; +} diff --git a/src/runtime/mod.tsx b/src/runtime/mod.tsx index 05ba1bc..a428f19 100644 --- a/src/runtime/mod.tsx +++ b/src/runtime/mod.tsx @@ -1,13 +1,18 @@ import { h, hydrate } from "../../deps/preact/mod.ts"; import type { ComponentType } from "../../deps/preact/mod.ts"; -import { useEffect, useMemo, useState } from "../../deps/preact/hooks.ts"; +import { + useCallback, + useEffect, + useMemo, + useState, +} from "../../deps/preact/hooks.ts"; import type { AppProps, PageProps } from "./type.ts"; import { Router } from "./router/router.ts"; -import { useLocation } from "./router/location.ts"; import { initRouter } from "./router/interceptor.ts"; +import { locationCtx } from "./router/location.ts"; +import { memo } from "./memo.js"; type Route = [route: string, data: RouteData]; - type RouteData = [ component: () => Promise<{ default: ComponentType }>, hasStaticData: boolean, @@ -15,18 +20,15 @@ type RouteData = [ export async function start(routes: Route[], app: ComponentType) { const router = new Router(routes); - const path = location.pathname; + const path = window.location.pathname; const [route] = router.getRoute(path); if (!route) throw new Error("Failed to match inital route."); const initialPage = await loadComponent(route[1][0](), route[1][1], path); - // sets up event listeners on elements - initRouter(router); - hydrate( , - document.getElementById("__dext")!, + window.document.getElementById("__dext")!, ); } @@ -39,36 +41,75 @@ function Dext(props: { app: ComponentType; initialPage: PageComponent; }) { - const [path] = useLocation(); - const [route, match] = useMemo(() => props.router.getRoute(path), [ - props.router, - path, - ]); + const [desiredPath, setDesiredPath] = useState(window.location.pathname); + const [desiredRoute, desiredMatch] = useMemo( + () => props.router.getRoute(desiredPath), + [props.router, desiredPath], + ); - const [[Page], setPage] = useState<[PageComponent | null]>([ - props.initialPage, - ]); + const navigate = useCallback( + (to: string) => { + window.history.pushState(null, "", to); + setDesiredPath(to); + }, + [setDesiredPath], + ); + + useEffect(() => { + // sets up event listeners on elements + initRouter(props.router, navigate); + + window.addEventListener("popstate", (event) => { + setDesiredPath(window.location.pathname); + }); + }, [props.router, navigate]); + + const [page, setPage] = useState< + [PageComponent | null, string, Record] + >([props.initialPage, desiredPath, desiredMatch]); useEffect(() => { let cancelled = false; - if (route) { - loadComponent(route[1][0](), route[1][1], path).then((page) => { - if (!cancelled) setPage([page]); - }); + if (desiredRoute) { + loadComponent(desiredRoute[1][0](), desiredRoute[1][1], desiredPath) + .then((page) => { + if (!cancelled) { + setPage([page, desiredPath, desiredMatch]); + } + }) + .catch((err) => { + if (!cancelled) { + console.error(err); + window.location.pathname = desiredPath; + } + }); } else { - setPage([null]); + setPage([null, desiredPath, desiredMatch]); } () => (cancelled = true); - }, [route]); + }, [desiredRoute, desiredPath, desiredMatch]); - const App = props.app; - return ( -

- {Page === null ? : } -
- ); + return ; } +const DextPage = memo( + (props: { + App: ComponentType; + page: [PageComponent | null, string, Record]; + navigate: (to: string) => void; + }) => { + const { App, page, navigate } = props; + const [Page, path, match] = page; + return ( + +
+ {Page === null ? : } +
+
+ ); + }, +); + async function loadComponent( componentPromise: Promise<{ default: ComponentType }>, hasStaticData: boolean, diff --git a/src/runtime/prerender_page_host.tsx b/src/runtime/prerender_page_host.tsx index 0dca6ba..b1f03f1 100644 --- a/src/runtime/prerender_page_host.tsx +++ b/src/runtime/prerender_page_host.tsx @@ -16,6 +16,8 @@ const { data, route, path } = rawData.length == 0 ? undefined : JSON.parse(new TextDecoder().decode(rawData)); window.location = { pathname: path } as Location; +// @ts-expect-error because this is a hidden variable. +window.__DEXT_SSR = true; const body = render(
diff --git a/src/runtime/router/interceptor.ts b/src/runtime/router/interceptor.ts index 3797d27..e849f28 100644 --- a/src/runtime/router/interceptor.ts +++ b/src/runtime/router/interceptor.ts @@ -1,12 +1,14 @@ -// deno-lint-ignore-file no-undef import type { Router } from "./router.ts"; -export function initRouter(router: Router) { +export function initRouter( + router: Router, + navigate: (to: string) => void, +) { function routeFromLink(node: HTMLAnchorElement) { // only valid elements if (!node || !node.getAttribute) return; - let href = node.getAttribute("href"), + const href = node.getAttribute("href"), target = node.getAttribute("target"); // ignore links with targets and non-path URLs @@ -20,7 +22,7 @@ export function initRouter(router: Router) { const [route] = router.getRoute(href); if (route) { - history.pushState(null, "", href); + navigate(href); return true; } diff --git a/src/runtime/router/location.ts b/src/runtime/router/location.ts index cbdd9a1..e16cd90 100644 --- a/src/runtime/router/location.ts +++ b/src/runtime/router/location.ts @@ -1,87 +1,16 @@ -// deno-lint-ignore-file no-undef -import { - useCallback, - useEffect, - useRef, - useState, -} from "../../../deps/preact/hooks.ts"; +import { createContext } from "../../../deps/preact/mod.ts"; +import { useContext } from "../../../deps/preact/hooks.ts"; -/** - * History API docs @see https://developer.mozilla.org/en-US/docs/Web/API/History - */ -const eventPopstate = "popstate"; -const eventPushState = "pushState"; -const eventReplaceState = "replaceState"; -export const events = [ - eventPopstate, - eventPushState, - eventReplaceState, -] as const; +export const locationCtx = createContext<[string, (to: string) => void]>( + ["", () => {}], +); export function useLocation() { - const [path, update] = useState(location.pathname); - const prevPath = useRef(path); - - useEffect(() => { - patchHistoryEvents(); - - // this function checks if the location has been changed since the - // last render and updates the state only when needed. - // unfortunately, we can't rely on `path` value here, since it can be stale, - // that's why we store the last pathname in a ref. - const checkForUpdates = () => { - const { pathname } = location; - prevPath.current !== pathname && update((prevPath.current = pathname)); - }; - - events.map((e) => addEventListener(e, checkForUpdates)); - - // it's possible that an update has occurred between render and the effect handler, - // so we run additional check on mount to catch these updates. Based on: - // https://gist.github.com/bvaughn/e25397f70e8c65b0ae0d7c90b731b189 - checkForUpdates(); - - return () => events.map((e) => removeEventListener(e, checkForUpdates)); - }, []); - - // the 2nd argument of the `useLocation` return value is a function - // that allows to perform a navigation. - // - // the function reference should stay the same between re-renders, so that - // it can be passed down as an element prop without any performance concerns. - const navigate = useCallback( - (to: string, { replace = false } = {}) => - history[replace ? eventReplaceState : eventPushState](0, "", to), - [], - ); - - return [path, navigate] as const; + // @ts-expect-error because this is a hidden variable. + if (window.__DEXT_SSR) { + return [window.location.pathname, () => { + throw new TypeError("Can not navigate in SSR context."); + }]; + } + return useContext(locationCtx); } - -// While History API does have `popstate` event, the only -// proper way to listen to changes via `push/replaceState` -// is to monkey-patch these methods. -// -// See https://stackoverflow.com/a/4585031 - -let patched = 0; - -const patchHistoryEvents = () => { - if (patched) return; - - ([eventPushState, eventReplaceState] as const).map((type) => { - const original = history[type]; - history[type] = function ( - data: unknown, - title: string, - url?: string | null | undefined, - ) { - const result = original.apply(this, [data, title, url]); - const event = new Event(type); - dispatchEvent(event); - return result; - }; - }); - - return (patched = 1); -}; diff --git a/src/runtime/router/matcher.ts b/src/runtime/router/matcher.ts index da73ecb..d6cb687 100644 --- a/src/runtime/router/matcher.ts +++ b/src/runtime/router/matcher.ts @@ -1,5 +1,5 @@ export function makeMatcher() { - let cache: Record< + const cache: Record< string, { keys: { name: string; repeat: boolean }[]; @@ -53,8 +53,8 @@ const pathToRegexp = (pattern: string) => { let match = null, lastIndex = 0, - keys = [], result = ""; + const keys = []; while ((match = groupRx.exec(pattern)) !== null) { const [_, segment, mod] = match; diff --git a/src/runtime/tsconfig.json b/src/runtime/tsconfig.json index fe468c1..f41d4f1 100644 --- a/src/runtime/tsconfig.json +++ b/src/runtime/tsconfig.json @@ -3,8 +3,6 @@ "lib": ["esnext", "dom", "deno.ns"], "jsx": "react", "jsxFactory": "h", - "jsxFragmentFactory": "Fragment", - "importsNotUsedAsValues": "error", - "isolatedModules": true + "jsxFragmentFactory": "Fragment" } } diff --git a/tests/fixtures/dataHooks/get_static_data_let.ts b/tests/fixtures/dataHooks/get_static_data_let.ts index e2665f1..ef18d75 100644 --- a/tests/fixtures/dataHooks/get_static_data_let.ts +++ b/tests/fixtures/dataHooks/get_static_data_let.ts @@ -1 +1 @@ -export let getStaticData = () => {}; +export const getStaticData = () => {}; diff --git a/tests/fixtures/full/custom_app_and_document/tsconfig.json b/tests/fixtures/full/custom_app_and_document/tsconfig.json index fe468c1..f41d4f1 100644 --- a/tests/fixtures/full/custom_app_and_document/tsconfig.json +++ b/tests/fixtures/full/custom_app_and_document/tsconfig.json @@ -3,8 +3,6 @@ "lib": ["esnext", "dom", "deno.ns"], "jsx": "react", "jsxFactory": "h", - "jsxFragmentFactory": "Fragment", - "importsNotUsedAsValues": "error", - "isolatedModules": true + "jsxFragmentFactory": "Fragment" } } diff --git a/tests/fixtures/full/simple/tsconfig.json b/tests/fixtures/full/simple/tsconfig.json index fe468c1..f41d4f1 100644 --- a/tests/fixtures/full/simple/tsconfig.json +++ b/tests/fixtures/full/simple/tsconfig.json @@ -3,8 +3,6 @@ "lib": ["esnext", "dom", "deno.ns"], "jsx": "react", "jsxFactory": "h", - "jsxFragmentFactory": "Fragment", - "importsNotUsedAsValues": "error", - "isolatedModules": true + "jsxFragmentFactory": "Fragment" } } diff --git a/tests/fixtures/full/static_generation/tsconfig.json b/tests/fixtures/full/static_generation/tsconfig.json index fe468c1..f41d4f1 100644 --- a/tests/fixtures/full/static_generation/tsconfig.json +++ b/tests/fixtures/full/static_generation/tsconfig.json @@ -3,8 +3,6 @@ "lib": ["esnext", "dom", "deno.ns"], "jsx": "react", "jsxFactory": "h", - "jsxFragmentFactory": "Fragment", - "importsNotUsedAsValues": "error", - "isolatedModules": true + "jsxFragmentFactory": "Fragment" } }