Skip to content

Commit

Permalink
feat: SSR Contexts
Browse files Browse the repository at this point in the history
- react-router examples
- Removed Router.svelte (in favor of using react:RouterProvider directly)
- Cleaner & optional RouterContext
  • Loading branch information
bfanger committed Dec 2, 2022
1 parent 72cbcc9 commit 9bbe6f8
Show file tree
Hide file tree
Showing 29 changed files with 933 additions and 724 deletions.
3 changes: 3 additions & 0 deletions .eslintrc
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,8 @@
"parserOptions": {
"project": "./tsconfig.eslint.json",
"extraFileExtensions": [".svelte"]
},
"rules": {
"import/extensions": "off"
}
}
8 changes: 6 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,12 +50,16 @@ sveltifyReact creates a single React Root and based on the Hierachy of the React

The `<Bridge>`s use React Portals to render the components into the DOM of the ReactWrapper Svelte component.

This is why the childeren prop passed to your React is an array, even when you manually pass a children prop.
This is why the childeren prop passed to your React component is an array, even when you manually pass a children prop.
This array allows svelte-preprocess-react to inject the slotted content into the correct place in the React tree.

### Server mode

Based off on how the Svelte component is compiled we can detect SSR and utilize the renderToString method th generate the html. (limited to leaf nodes a.t.m.)
Based off on how the Svelte component is compiled we can detect SSR and utilize the renderToString method to generate the html. (limited to leaf nodes a.t.m.)

This detection is done at runtime, so the client will also ship with the renderToStringYou server code.
For smaller bundle size you can disable this feature by passing `ssr: false` to the preprocess function.

ssr-portals: The default slot (svelte) is rendered first, if the slot contains react components a placeholder string is rendered. These child components are passed as children to the react component and are wrapped with marker tags.
This allows both frameworks to maintain their component trees (needed for context)
Then the html partials are extracted moved into place so the resulting html looks like it was one component tree.
16 changes: 7 additions & 9 deletions docs/react-router.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,19 +39,17 @@ In src/routes/+layout.svelte

```svelte
<script lang="ts">
import Router from "svelte-preprocess-react/react-router/Router.svelte";
import { used } from "svelte-preprocess-react";
import { RouterProvider } from "svelte-preprocess-react/react-router";
import { page } from "$app/stores";
import { goto } from "$app/navigation";
used(RouterProvider);
</script>
<Router
location={$page.url}
params={$page.params}
push={(url) => goto(url)}
replace={(url) => goto(url, { replaceState: true })}
>
<react:RouterProvider value={{ url: $page.url, params: $page.params, goto }}>
<slot />
</Router>
</react:RouterProvider>
```

As you can see the `<Router>` is exposing the push & replace actions but the actual navigation and url updates are done by the SvelteKit router.
the actual navigation and url updates are done by the SvelteKit router. The `<RouterProvider>` exposed the current url and push & replace actions
9 changes: 5 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@
]
},
"devDependencies": {
"@playwright/test": "^1.25.0",
"@playwright/test": "^1.28.1",
"@sveltejs/adapter-static": "next",
"@sveltejs/kit": "next",
"@sveltejs/package": "^1.0.0-next.1",
Expand All @@ -69,8 +69,8 @@
"eslint-plugin-only-warn": "^1.0.3",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-svelte3": "^4.0.0",
"happy-dom": "^7.6.6",
"husky": "^8.0.1",
"jsdom": "^20.0.3",
"lint-staged": "^13.0.3",
"postcss": "^8.4.16",
"prettier": "^2.7.1",
Expand All @@ -85,13 +85,14 @@
"typescript": "^4.7.4",
"vite": "^3.0.6",
"vite-tsconfig-paths": "^3.5.0",
"vitest": "^0.24.3"
"vitest": "^0.25.3"
},
"dependencies": {
"magic-string": "^0.26.2"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
"react-dom": ">=16.8.0",
"svelte": ">=3.0.0"
}
}
2 changes: 1 addition & 1 deletion src/app.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,6 @@
%sveltekit.head%
</head>
<body>
%sveltekit.body%
<svelte-app style="display: contents">%sveltekit.body%</svelte-app>
</body>
</html>
6 changes: 3 additions & 3 deletions src/lib/preprocessReact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,15 +219,15 @@ function replaceReactTags(
replaceReactTags(child, content, components);
});
// traverse else branch of IfBlock
node.else?.children?.forEach((child) => {
node.else?.children?.forEach((child: TemplateNode) => {
replaceReactTags(child, content, components);
});
// traverse then branch of AwaitBlock
node.then?.children?.forEach((child) => {
node.then?.children?.forEach((child: TemplateNode) => {
replaceReactTags(child, content, components);
});
// traverse catch branch of AwaitBlock
node.catch?.children?.forEach((child) => {
node.catch?.children?.forEach((child: TemplateNode) => {
replaceReactTags(child, content, components);
});
return components;
Expand Down
20 changes: 5 additions & 15 deletions src/lib/react-router/Link.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,29 +16,19 @@ const Link = React.forwardRef<HTMLAnchorElement, LinkProps>(
const attrs = rest;
const context = React.useContext(RouterContext);
if (!context) {
let pathname = "";
if (typeof to === "string") {
pathname = to;
} else if (typeof to === "object") {
pathname = to.pathname;
}
if (
replace ||
pathname.startsWith("/") === false ||
/^[a+z]+:\/\//.test(pathname) === false
) {
// Without context only absolute paths are supported.
throw new Error("Link was not wrapped inside a <Router>");
if (replace) {
console.warn("replace attribute <Link> needs a <Router.Provider>");
}
}
const href = locationToUrl(to, context?.base).toString();

const href = locationToUrl(to, context?.url).toString();
if (replace) {
const { onClick } = attrs;
attrs.onClick = (event) => {
onClick?.(event);
if (!event.defaultPrevented) {
event.preventDefault();
context?.history.replace(href);
context?.goto(href, { replaceState: true });
}
};
}
Expand Down
4 changes: 2 additions & 2 deletions src/lib/react-router/NavLink.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ const NavLink: React.FC<NavLinkProps> = ({
}) => {
const context = useRouterContext();
const attrs: LinkProps = rest;
const target = locationToUrl(attrs.to, context.base).toString();
const current = locationToUrl(context.location, context.base).toString();
const target = locationToUrl(attrs.to, context.url).toString();
const current = locationToUrl(context.url, context.url).toString();
const isActive = target === current;
const condition: RouteCondition = { isActive };
if (typeof className === "function") {
Expand Down
30 changes: 0 additions & 30 deletions src/lib/react-router/Router.svelte

This file was deleted.

3 changes: 3 additions & 0 deletions src/lib/react-router/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import RouterContext from "./internal/RouterContext.js";

export * from "./types.js";
export const RouterProvider = RouterContext.Provider;
export { default as Link } from "./Link.js";
export { default as NavLink } from "./NavLink.js";
export { default as useLocation } from "./useLocation.js";
Expand Down
12 changes: 4 additions & 8 deletions src/lib/react-router/internal/RouterContext.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,10 @@
import * as React from "react";
import type { Location, Params } from "../types";
import type { Params } from "../types";

type RouterContextType = {
base: string;
location: Location;
export type RouterContextType = {
url: URL;
params: Params;
history: {
push(url: string): void;
replace(url: string): void;
};
goto(url: string, opts?: { replaceState?: boolean }): void;
};
const RouterContext = React.createContext<RouterContextType | undefined>(
undefined
Expand Down
40 changes: 24 additions & 16 deletions src/lib/react-router/internal/locationToUrl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,34 +3,42 @@ import type { To } from "../types";
/**
* Convert a react-router location to an URL.
*/
export default function locationToUrl(to: To, base = "http://localhost/"): URL {
export default function locationToUrl(to: To, base?: URL): URL {
const pathname = typeof to === "string" ? to : to.pathname;
let url: URL;
const baseUrl = new URL(
base ??
(typeof window === "undefined"
? "http://localhost"
: window.location.href)
);

if (typeof to === "string" && /^[a-z]+:\/\//.test(to)) {
// Absolute URL incl domain
return new URL(to, base);
}
if (pathname === ".") {
url = new URL(to, baseUrl);
} else if (pathname === ".") {
// react-router uses "." to represent the current location
url = new URL(base);
url = new URL(baseUrl);
} else if (pathname.startsWith("/")) {
// Absolute path (same domain)
url = new URL(pathname, base);
url = new URL(pathname, baseUrl);
} else if (pathname === "..") {
baseUrl.pathname += "/";
url = new URL(pathname, baseUrl);
url.pathname = url.pathname.substring(0, url.pathname.length - 1);
} else {
// react-router's relative path
url = new URL(pathname, base?.endsWith("/") ? base : `${base}/`);
if (url.pathname.length > 1 && url.pathname.endsWith("/")) {
// Remove trailing slash
url.pathname = url.pathname.substring(0, url.pathname.length - 1);
}
// relative path
url = new URL(pathname, baseUrl);
}
if (typeof to === "object") {
url.search = to.search;
url.hash = to.hash;
}
url.toString = function toString() {
// Strip the origin from the URL
return URL.prototype.toString.call(url).substring(this.origin.length);
};
if (url.origin === baseUrl.origin) {
url.toString = function toString() {
// Strip the origin from the URL
return URL.prototype.toString.call(url).substring(this.origin.length);
};
}
return url;
}
22 changes: 19 additions & 3 deletions src/lib/react-router/internal/useRouterContext.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,26 @@
import * as React from "react";
import RouterContext from "./RouterContext.js";
import RouterContext, { type RouterContextType } from "./RouterContext.js";

export default function useRouterContext() {
export default function useRouterContext(): RouterContextType {
const context = React.useContext(RouterContext);
if (!context) {
throw new Error("Component was not wrapped inside a <Router>");
console.warn("Component was not wrapped inside a <react:RouterProvider>");
return {
url: new URL(
typeof window !== "undefined"
? window.location.href
: "http://localhost/"
),
params: {},
goto,
};
}
return context;
}

function goto(url: string) {
console.warn(
"No access to <react:RouterProvider>, falling back to using browser navigation"
);
window.location.href = url;
}
15 changes: 13 additions & 2 deletions src/lib/react-router/useHistory.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,17 @@
import * as React from "react";
import useRouterContext from "./internal/useRouterContext.js";

export default function useHistory() {
const { history } = useRouterContext();
return history;
const { goto } = useRouterContext();
return React.useMemo(
() => ({
push(url: string) {
goto(url);
},
replace(url: string) {
goto(url, { replaceState: true });
},
}),
[goto]
);
}
11 changes: 5 additions & 6 deletions src/lib/react-router/useLocation.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
import * as React from "react";
import useRouterContext from "./internal/useRouterContext.js";
import locationToUrl from "./internal/locationToUrl.js";
import type { Location } from "./types";

export default function useLocation(): Location {
const {
base,
location: { pathname, search, hash },
url: { pathname, search, hash },
} = useRouterContext();

return React.useMemo(() => {
return locationToUrl({ pathname, search, hash }, base);
}, [hash, pathname, search, base]);
return React.useMemo(
() => ({ pathname, search, hash }),
[hash, pathname, search]
);
}
Loading

0 comments on commit 9bbe6f8

Please sign in to comment.