Skip to content

Commit

Permalink
feat: extract StartClient, StartServer and Meta from @tanstack/start (#…
Browse files Browse the repository at this point in the history
…64)

* feat: refactor-tanstack-start

* chore: changelog

* refactor: code style

* docs: add credits

---------

Co-authored-by: 高艳兵 <[email protected]>
Co-authored-by: sorrycc <[email protected]>
  • Loading branch information
3 people authored Nov 23, 2024
1 parent 3aac455 commit 9812357
Show file tree
Hide file tree
Showing 16 changed files with 884 additions and 2,978 deletions.
5 changes: 5 additions & 0 deletions .changeset/spicy-cycles-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@umijs/tnf': patch
---

refactor(ssr): extract StartClient, StartServer and Meta from @tanstack/start
12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ Tnf, ~~the north face~~, the next framework. Tnf is focused on simple, performan
- Tailwind CSS support built-in.
- [Framework unified plugin system](./docs/plugin.md) which is compatible with umi and other frameworks.
- [ ] Security built-in. Including doctor rules which is used in Ant Group.
- [ ] Support SSR, API routes and server functions.
- Support SSR.
- [ ] Support API routes and server functions.
- [ ] AI based generator and other features.
- [ ] Rust based for heavy computation tasks.
- [ ] Easy to integrate with popular libraries.
Expand Down Expand Up @@ -59,8 +60,9 @@ $ pnpm preview

## API

- `@umijs/tnf/router`: The router module, reexported from `@tanstack/react-router`.
- `@umijs/tnf`: The entry of tnf, including `defineConfig`, ...
- `@umijs/tnf/router`: The router module, reexported from `@tanstack/react-router`.
- `@umijs/tnf/ssr`: The ssr module, including `Meta`, `Client` and `Server`.

## Config

Expand Down Expand Up @@ -89,6 +91,12 @@ const Route = createFileRoute('/foo')({
});
```

## CREDITS

This project is inspired by:

- [@tanstack/router](https://github.com/TanStack/router) for the router and ssr.

## LICENSE

[MIT](LICENSE)
41 changes: 41 additions & 0 deletions client/ssr/Asset.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import React from 'react';
import type { RouterManagedTag } from '@tanstack/react-router';

export function Asset({ tag, attrs, children }: RouterManagedTag) {
switch (tag) {
case 'title':
return (
<title {...attrs} suppressHydrationWarning>
{children}
</title>
);
case 'meta':
return <meta {...attrs} suppressHydrationWarning />;
case 'link':
return <link {...attrs} suppressHydrationWarning />;
case 'style':
return (
<style
{...attrs}
dangerouslySetInnerHTML={{ __html: children || '' }}
/>
);
case 'script':
if (attrs && attrs.src) {
return <script {...attrs} suppressHydrationWarning />;
}
if (typeof children === 'string')
return (
<script
{...attrs}
dangerouslySetInnerHTML={{
__html: children,
}}
suppressHydrationWarning
/>
);
return null;
default:
return null;
}
}
20 changes: 20 additions & 0 deletions client/ssr/Client.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import React from 'react';
import { RouterProvider } from '@tanstack/react-router';
import type { AnyRouter } from '@tanstack/react-router';
import { afterHydrate } from './utils/serialization';

let cleaned = false;

export function Client(props: { router: AnyRouter }) {
if (!props.router.state.matches.length) {
props.router.hydrate();
afterHydrate({ router: props.router });
}

if (!cleaned) {
cleaned = true;
window.__TSR__?.cleanScripts();
}

return <RouterProvider router={props.router} />;
}
31 changes: 31 additions & 0 deletions client/ssr/Context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { createContext } from 'react';

class Context {
cache = new Map();

private static instance: Context | undefined;

public static create(): Context {
if (!Context.instance) {
Context.instance = new Context();
}

return Context.instance;
}

private createContext<T>(key: string, initialValue: T) {
const context = createContext(initialValue);

this.cache.set(key, context);

return context;
}

get<T>(key: string, initialValue?: T) {
return this.cache.get(key) || this.createContext(key, initialValue);
}
}

const context = Context.create();

export default context;
158 changes: 158 additions & 0 deletions client/ssr/Meta.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import * as React from 'react';
import { ScriptOnce, useRouter, useRouterState } from '@tanstack/react-router';
import type {
MakeRouteMatch,
RouterManagedTag,
RouterState,
} from '@tanstack/react-router';
import jsesc from 'jsesc';
import { Asset } from './Asset';
import Context from './Context';

export const useMeta = (): RouterManagedTag[] => {
const router = useRouter();

const routeMeta = useRouterState({
select: (state) => {
return state.matches.map((match) => match.meta!).filter(Boolean);
},
});

const meta: RouterManagedTag[] = React.useMemo(() => {
const resultMeta: RouterManagedTag[] = [];
const metaByName: Record<string, boolean> = {};
let title: RouterManagedTag | undefined;
[...routeMeta].reverse().forEach((metas) => {
[...metas].reverse().forEach((m) => {
if (!m) return;

if (m.title) {
if (!title) {
title = {
tag: 'title',
children: m.title,
};
}
} else {
if (m.name) {
if (metaByName[m.name]) {
return;
} else {
metaByName[m.name] = true;
}
}

resultMeta.push({
tag: 'meta',
attrs: {
...m,
},
});
}
});
});

if (title) {
resultMeta.push(title);
}

resultMeta.reverse();

return resultMeta;
}, [routeMeta]);

const links = useRouterState({
select: (state: RouterState<any, MakeRouteMatch<any>>) =>
state.matches
.map((match) => match.links!)
.filter(Boolean)
.flat(1)
.map((link) => ({
tag: 'link',
attrs: {
...link,
},
})) as RouterManagedTag[],
});

const preloadMeta = useRouterState({
select: (state: RouterState<any, MakeRouteMatch<any>>) => {
const preloadMeta: RouterManagedTag[] = [];

state.matches
.map((match) => router.looseRoutesById[match.routeId]!)
.forEach((route) =>
router.manifest?.routes[route.id]?.preloads
?.filter(Boolean)
.forEach((preload) => {
preloadMeta.push({
tag: 'link',
attrs: {
rel: 'modulepreload',
href: preload,
},
});
}),
);

return preloadMeta;
},
});

return uniqBy([...meta, ...preloadMeta, ...links], (d) => {
return JSON.stringify(d);
});
};

function uniqBy<T>(arr: T[], fn: (item: T) => string): T[] {
const seen = new Set<string>();
return arr.filter((item) => {
const key = fn(item);
if (seen.has(key)) {
return false;
}
seen.add(key);
return true;
});
}

export const useMetaElements = (): JSX.Element => {
const router = useRouter();
const meta = useMeta();

const dehydratedCtx = React.useContext(
Context.get('TanStackRouterHydrationContext', {}),
);

return (
<>
{meta.map((asset, i) => (
<Asset {...asset} key={`tsr-meta-${JSON.stringify(asset)}`} />
))}
<>
<ScriptOnce
log={false}
children={`__TSR__={matches:[],streamedValues:{},queue:[],runQueue:()=>{let e=!1;__TSR__.queue=__TSR__.queue.filter((_=>!_()||(e=!0,!1))),e&&__TSR__.runQueue()},initMatch:e=>{__TSR__.queue.push((()=>(__TSR__.matches[e.index]||(__TSR__.matches[e.index]=e,Object.entries(e.extracted).forEach((([e,_])=>{if("stream"===_.type){let e;_.value=new ReadableStream({start(_){e=_}}),_.value.controller=e}else if("promise"===_.type){let e,t;_.value=new Promise(((_,u)=>{e=_,t=u})),_.resolve=e,_.reject=t}}))),!0))),__TSR__.runQueue()},resolvePromise:e=>{__TSR__.queue.push((()=>{const _=__TSR__.matches[e.matchIndex];if(_){const t=_.extracted[e.id];if(t)return t.resolve(e.value.data),!0}return!1})),__TSR__.runQueue()},cleanScripts:()=>{document.querySelectorAll(".tsr-once").forEach((e=>{e.remove()}))}};`}
/>
<ScriptOnce
children={`__TSR__.dehydrated = ${jsesc(
router.options.transformer.stringify(dehydratedCtx),
{
isScriptContext: true,
wrap: true,
json: true,
},
)}`}
/>
</>
</>
);
};

/**
* @description The `Meta` component is used to render meta tags and links for the current route.
* It should be rendered in the `<head>` of your document.
*/
export const Meta = (): JSX.Element => {
return <>{useMetaElements()}</>;
};
32 changes: 32 additions & 0 deletions client/ssr/Server.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import * as React from 'react';
import { RouterProvider } from '@tanstack/react-router';
import type { AnyRouter } from '@tanstack/react-router';
import jsesc from 'jsesc';
import Context from './Context';
import { AfterEachMatch } from './utils/serialization';

export function Server<TRouter extends AnyRouter>(props: { router: TRouter }) {
props.router.AfterEachMatch = AfterEachMatch;
props.router.serializer = (value) =>
jsesc(value, {
isScriptContext: true,
wrap: true,
json: true,
});

const hydrationContext = Context.get('TanStackRouterHydrationContext', {});

const hydrationCtxValue = React.useMemo(
() => ({
router: props.router.dehydrate(),
payload: props.router.options.dehydrate?.(),
}),
[props.router],
);

return (
<hydrationContext.Provider value={hydrationCtxValue}>
<RouterProvider router={props.router} />
</hydrationContext.Provider>
);
}
18 changes: 18 additions & 0 deletions client/ssr/ssr.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { ReactNode } from 'react';
import type { AnyRouter } from '@tanstack/react-router';

export interface StartClientProps {
router: AnyRouter;
}

export interface AssetProps {
tag: 'title' | 'meta' | 'link' | 'style' | 'script';
attrs?: Record<string, any>;
children?: ReactNode;
}

export function StartClient(props: StartClientProps): JSX.Element;
export function StartServer<TRouter extends AnyRouter>(props: {
router: TRouter;
}): JSX.Element;
export function Meta(): JSX.Element;
4 changes: 4 additions & 0 deletions client/ssr/ssr.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export { Client } from './Client';
export { Server } from './Server';
export { Meta } from './Meta';

Loading

0 comments on commit 9812357

Please sign in to comment.