@fastify/react 1.0.2 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/context.js +5 -0
- package/package.json +13 -3
- package/plugin/index.js +4 -20
- package/plugin/preload.js +8 -3
- package/plugin/virtual.js +43 -9
- package/rendering.js +25 -12
- package/routing.js +7 -3
- package/server.js +1 -1
- package/virtual/core.jsx +3 -2
- package/virtual/create.jsx +15 -1
- package/virtual/mount.js +7 -3
- package/virtual-ts/context.ts +4 -0
- package/virtual-ts/core.tsx +136 -0
- package/virtual-ts/create.tsx +19 -0
- package/virtual-ts/index.ts +7 -0
- package/virtual-ts/layouts/default.tsx +8 -0
- package/virtual-ts/layouts.ts +20 -0
- package/virtual-ts/mount.ts +58 -0
- package/virtual-ts/resource.ts +82 -0
- package/virtual-ts/root.tsx +29 -0
- package/virtual-ts/routes.ts +1 -0
package/context.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { createHead } from '@unhead/react/server'
|
|
2
|
+
|
|
1
3
|
const routeContextInspect = Symbol.for('nodejs.util.inspect.custom')
|
|
2
4
|
|
|
3
5
|
export default class RouteContext {
|
|
@@ -15,10 +17,12 @@ export default class RouteContext {
|
|
|
15
17
|
}
|
|
16
18
|
|
|
17
19
|
constructor(server, req, reply, route) {
|
|
20
|
+
this.app = null
|
|
18
21
|
this.server = server
|
|
19
22
|
this.req = req
|
|
20
23
|
this.reply = reply
|
|
21
24
|
this.head = {}
|
|
25
|
+
this.useHead = createHead()
|
|
22
26
|
this.actionData = {}
|
|
23
27
|
this.state = null
|
|
24
28
|
this.data = route.data
|
|
@@ -46,6 +50,7 @@ export default class RouteContext {
|
|
|
46
50
|
actionData: this.actionData,
|
|
47
51
|
state: this.state,
|
|
48
52
|
data: this.data,
|
|
53
|
+
head: this.head,
|
|
49
54
|
layout: this.layout,
|
|
50
55
|
getMeta: this.getMeta,
|
|
51
56
|
getData: this.getData,
|
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"main": "index.js",
|
|
4
4
|
"name": "@fastify/react",
|
|
5
5
|
"description": "The official @fastify/vite renderer for React",
|
|
6
|
-
"version": "1.0
|
|
6
|
+
"version": "1.1.0",
|
|
7
7
|
"files": [
|
|
8
8
|
"plugin/index.js",
|
|
9
9
|
"plugin/parsers.js",
|
|
@@ -21,6 +21,16 @@
|
|
|
21
21
|
"virtual/resource.js",
|
|
22
22
|
"virtual/root.jsx",
|
|
23
23
|
"virtual/routes.js",
|
|
24
|
+
"virtual-ts/layouts/default.tsx",
|
|
25
|
+
"virtual-ts/context.ts",
|
|
26
|
+
"virtual-ts/core.tsx",
|
|
27
|
+
"virtual-ts/create.tsx",
|
|
28
|
+
"virtual-ts/index.ts",
|
|
29
|
+
"virtual-ts/layouts.ts",
|
|
30
|
+
"virtual-ts/mount.ts",
|
|
31
|
+
"virtual-ts/resource.ts",
|
|
32
|
+
"virtual-ts/root.tsx",
|
|
33
|
+
"virtual-ts/routes.ts",
|
|
24
34
|
"client.js",
|
|
25
35
|
"context.js",
|
|
26
36
|
"index.js",
|
|
@@ -37,6 +47,7 @@
|
|
|
37
47
|
"./server": "./server.js"
|
|
38
48
|
},
|
|
39
49
|
"dependencies": {
|
|
50
|
+
"@unhead/react": "^2.0.8",
|
|
40
51
|
"acorn": "^8.14.1",
|
|
41
52
|
"acorn-strip-function": "^1.2.0",
|
|
42
53
|
"acorn-walk": "^8.3.4",
|
|
@@ -48,10 +59,9 @@
|
|
|
48
59
|
"react": "^19.1.0",
|
|
49
60
|
"react-dom": "^19.1.0",
|
|
50
61
|
"react-router": "^7.5.0",
|
|
51
|
-
"unihead": "latest",
|
|
52
62
|
"valtio": "latest",
|
|
53
63
|
"youch": "^3.3.4",
|
|
54
|
-
"@fastify/vite": "^8.
|
|
64
|
+
"@fastify/vite": "^8.1.2"
|
|
55
65
|
},
|
|
56
66
|
"devDependencies": {
|
|
57
67
|
"@biomejs/biome": "^1.9.2"
|
package/plugin/index.js
CHANGED
|
@@ -12,26 +12,18 @@ import { closeBundle } from './preload.js'
|
|
|
12
12
|
import { parseStateKeys } from './parsers.js'
|
|
13
13
|
import { generateStores } from './stores.js'
|
|
14
14
|
|
|
15
|
-
export default function viteFastifyReactPlugin () {
|
|
15
|
+
export default function viteFastifyReactPlugin ({ ts } = {}) {
|
|
16
16
|
const context = {
|
|
17
17
|
root: null,
|
|
18
18
|
}
|
|
19
19
|
return [viteFastify({
|
|
20
|
-
clientModule: '$app/index.js'
|
|
20
|
+
clientModule: ts ? '$app/index.ts' : '$app/index.js'
|
|
21
21
|
}), {
|
|
22
|
-
|
|
22
|
+
// https://vite.dev/guide/api-plugin#conventions
|
|
23
|
+
name: 'vite-plugin-react-fastify',
|
|
23
24
|
config,
|
|
24
25
|
configResolved: configResolved.bind(context),
|
|
25
26
|
resolveId: resolveId.bind(context),
|
|
26
|
-
configEnvironment (name, config, { mode }) {
|
|
27
|
-
if (mode === 'production') {
|
|
28
|
-
config.build.minify = true
|
|
29
|
-
config.build.sourcemap = false
|
|
30
|
-
}
|
|
31
|
-
if (name === 'ssr') {
|
|
32
|
-
config.build.manifest = false
|
|
33
|
-
}
|
|
34
|
-
},
|
|
35
27
|
async load (id) {
|
|
36
28
|
if (id.includes('?server') && !this.environment.config.build?.ssr) {
|
|
37
29
|
const source = loadSource(id)
|
|
@@ -44,14 +36,6 @@ export default function viteFastifyReactPlugin () {
|
|
|
44
36
|
if (prefix.test(id)) {
|
|
45
37
|
const [, virtual] = id.split(prefix)
|
|
46
38
|
if (virtual) {
|
|
47
|
-
if (virtual === 'stores') {
|
|
48
|
-
const contextPath = join(context.root, 'context.js')
|
|
49
|
-
if (existsSync(contextPath)) {
|
|
50
|
-
const keys = parseStateKeys(readFileSync(contextPath, 'utf8'))
|
|
51
|
-
return generateStores(keys)
|
|
52
|
-
}
|
|
53
|
-
return
|
|
54
|
-
}
|
|
55
39
|
return loadVirtualModule(virtual)
|
|
56
40
|
}
|
|
57
41
|
}
|
package/plugin/preload.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'
|
|
2
|
-
import { join, parse as parsePath } from 'node:path'
|
|
2
|
+
import { join, isAbsolute, parse as parsePath } from 'node:path'
|
|
3
3
|
import { HTMLRewriter } from 'html-rewriter-wasm'
|
|
4
4
|
|
|
5
5
|
const imageFileRE = /\.((png)|(jpg)|(svg)|(webp)|(gif))$/
|
|
@@ -10,13 +10,18 @@ export async function closeBundle(resolvedBundle) {
|
|
|
10
10
|
}
|
|
11
11
|
const { assetsInlineLimit } = this.environment.config.build
|
|
12
12
|
const { root, base } = this.environment.config
|
|
13
|
-
|
|
13
|
+
let distDir
|
|
14
|
+
if (isAbsolute(this.environment.config.build.outDir)) {
|
|
15
|
+
distDir = this.environment.config.build.outDir
|
|
16
|
+
} else {
|
|
17
|
+
distDir = join(root, this.environment.config.build.outDir)
|
|
18
|
+
}
|
|
14
19
|
const indexHtml = readFileSync(join(distDir, 'index.html'), 'utf8')
|
|
15
20
|
const pages = Object.fromEntries(
|
|
16
21
|
Object.entries(resolvedBundle ?? {})
|
|
17
22
|
.filter(([id, meta]) => {
|
|
18
23
|
if (meta.facadeModuleId?.includes('/pages/')) {
|
|
19
|
-
meta.htmlPath = meta.facadeModuleId.replace(/.*pages\/(.*)\.
|
|
24
|
+
meta.htmlPath = meta.facadeModuleId.replace(/.*pages\/(.*)\.(j|t)sx$/, 'html/$1.html')
|
|
20
25
|
return true
|
|
21
26
|
}
|
|
22
27
|
})
|
package/plugin/virtual.js
CHANGED
|
@@ -6,6 +6,7 @@ import { findExports } from 'mlly'
|
|
|
6
6
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
7
7
|
|
|
8
8
|
const virtualRoot = resolve(__dirname, '..', 'virtual')
|
|
9
|
+
const virtualRootTS = resolve(__dirname, '..', 'virtual-ts')
|
|
9
10
|
|
|
10
11
|
const virtualModules = [
|
|
11
12
|
'mount.js',
|
|
@@ -20,6 +21,19 @@ const virtualModules = [
|
|
|
20
21
|
'index.js',
|
|
21
22
|
]
|
|
22
23
|
|
|
24
|
+
const virtualModulesTS = [
|
|
25
|
+
'mount.ts',
|
|
26
|
+
'resource.ts',
|
|
27
|
+
'routes.ts',
|
|
28
|
+
'layouts.ts',
|
|
29
|
+
'create.tsx',
|
|
30
|
+
'root.tsx',
|
|
31
|
+
'layouts/',
|
|
32
|
+
'context.ts',
|
|
33
|
+
'core.tsx',
|
|
34
|
+
'index.ts',
|
|
35
|
+
]
|
|
36
|
+
|
|
23
37
|
export const prefix = /^\/?\$app\//
|
|
24
38
|
|
|
25
39
|
export async function resolveId (id) {
|
|
@@ -42,19 +56,34 @@ export async function resolveId (id) {
|
|
|
42
56
|
|
|
43
57
|
export function loadVirtualModule (virtualInput) {
|
|
44
58
|
let virtual = virtualInput
|
|
45
|
-
if (
|
|
46
|
-
virtual += '.js'
|
|
47
|
-
}
|
|
48
|
-
if (!virtualModules.includes(virtual)) {
|
|
59
|
+
if (!virtualModules.includes(virtual) && !virtualModulesTS.includes(virtual)) {
|
|
49
60
|
return
|
|
50
61
|
}
|
|
51
|
-
|
|
62
|
+
let virtualRootDir = virtualRoot
|
|
63
|
+
if (virtualInput.match(/\.tsx?$/)) {
|
|
64
|
+
virtualRootDir = virtualRootTS
|
|
65
|
+
}
|
|
66
|
+
const codePath = resolve(virtualRootDir, virtual)
|
|
52
67
|
return {
|
|
53
|
-
code,
|
|
68
|
+
code: readFileSync(codePath, 'utf8'),
|
|
54
69
|
map: null,
|
|
55
70
|
}
|
|
56
71
|
}
|
|
57
72
|
|
|
73
|
+
|
|
74
|
+
virtualModulesTS.includes = function (virtual) {
|
|
75
|
+
if (!virtual) {
|
|
76
|
+
return false
|
|
77
|
+
}
|
|
78
|
+
for (const entry of this) {
|
|
79
|
+
if (virtual.startsWith(entry)) {
|
|
80
|
+
return true
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return false
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
58
87
|
virtualModules.includes = function (virtual) {
|
|
59
88
|
if (!virtual) {
|
|
60
89
|
return false
|
|
@@ -67,11 +96,16 @@ virtualModules.includes = function (virtual) {
|
|
|
67
96
|
return false
|
|
68
97
|
}
|
|
69
98
|
|
|
70
|
-
function loadVirtualModuleOverride (viteProjectRoot,
|
|
71
|
-
|
|
99
|
+
function loadVirtualModuleOverride (viteProjectRoot, virtualInput) {
|
|
100
|
+
let virtual = virtualInput
|
|
101
|
+
if (!virtualModules.includes(virtual) && !virtualModulesTS.includes(virtual)) {
|
|
72
102
|
return
|
|
73
103
|
}
|
|
74
|
-
|
|
104
|
+
let overridePath = resolve(viteProjectRoot, virtual)
|
|
105
|
+
if (existsSync(overridePath)) {
|
|
106
|
+
return overridePath
|
|
107
|
+
}
|
|
108
|
+
overridePath = overridePath.replace('.js', '.ts')
|
|
75
109
|
if (existsSync(overridePath)) {
|
|
76
110
|
return overridePath
|
|
77
111
|
}
|
package/rendering.js
CHANGED
|
@@ -6,7 +6,7 @@ import { Minipass } from 'minipass'
|
|
|
6
6
|
// which enables the combination of React.lazy() and Suspense
|
|
7
7
|
import { renderToPipeableStream } from 'react-dom/server'
|
|
8
8
|
import * as devalue from 'devalue'
|
|
9
|
-
import
|
|
9
|
+
import { transformHtmlTemplate } from '@unhead/react/server'
|
|
10
10
|
import { createHtmlTemplates } from './templating.js'
|
|
11
11
|
|
|
12
12
|
// Helper function to get an AsyncIterable (via PassThrough)
|
|
@@ -46,6 +46,7 @@ export function onAllReady(app) {
|
|
|
46
46
|
export async function createRenderFunction ({ routes, create }) {
|
|
47
47
|
// Used when hydrating React Router on the client
|
|
48
48
|
const routeMap = Object.fromEntries(routes.map(_ => [_.path, _]))
|
|
49
|
+
|
|
49
50
|
// Registered as reply.render()
|
|
50
51
|
return function () {
|
|
51
52
|
if (this.request.route.streaming) {
|
|
@@ -80,6 +81,7 @@ export async function createHtmlFunction (source, _, config) {
|
|
|
80
81
|
return async function () {
|
|
81
82
|
const { routes, context, body } = await this.render()
|
|
82
83
|
|
|
84
|
+
context.useHead.push(context.head)
|
|
83
85
|
this.type('text/html')
|
|
84
86
|
|
|
85
87
|
// Use template with client module import removed
|
|
@@ -116,24 +118,35 @@ export async function createHtmlFunction (source, _, config) {
|
|
|
116
118
|
}
|
|
117
119
|
}
|
|
118
120
|
|
|
119
|
-
export function sendClientOnlyShell (templates, context
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
121
|
+
export async function sendClientOnlyShell (templates, context) {
|
|
122
|
+
return await transformHtmlTemplate(
|
|
123
|
+
context.useHead,
|
|
124
|
+
`${
|
|
125
|
+
templates.beforeElement(context)
|
|
126
|
+
}${
|
|
127
|
+
templates.afterElement(context)
|
|
128
|
+
}`
|
|
129
|
+
)
|
|
126
130
|
}
|
|
127
131
|
|
|
128
132
|
export function streamShell (templates, context, body) {
|
|
129
|
-
context.head = new Head(context.head).render()
|
|
130
133
|
return Readable.from(createShellStream(templates, context, body))
|
|
131
134
|
}
|
|
132
135
|
|
|
133
136
|
async function * createShellStream (templates, context, body) {
|
|
134
|
-
yield
|
|
137
|
+
yield await transformHtmlTemplate(
|
|
138
|
+
context.useHead,
|
|
139
|
+
templates.beforeElement(context)
|
|
140
|
+
)
|
|
141
|
+
|
|
135
142
|
for await (const chunk of body) {
|
|
136
|
-
yield
|
|
143
|
+
yield await transformHtmlTemplate(
|
|
144
|
+
context.useHead,
|
|
145
|
+
chunk.toString()
|
|
146
|
+
)
|
|
137
147
|
}
|
|
138
|
-
yield
|
|
148
|
+
yield await transformHtmlTemplate(
|
|
149
|
+
context.useHead,
|
|
150
|
+
templates.afterElement(context)
|
|
151
|
+
)
|
|
139
152
|
}
|
package/routing.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs'
|
|
2
|
-
import { join } from 'node:path'
|
|
2
|
+
import { join, isAbsolute } from 'node:path'
|
|
3
3
|
import Youch from 'youch'
|
|
4
4
|
import RouteContext from './context.js'
|
|
5
5
|
import { createHtmlFunction } from './rendering.js'
|
|
@@ -111,8 +111,12 @@ export async function createRoute ({ client, errorHandler, route }, scope, confi
|
|
|
111
111
|
handler = (_, reply) => reply.html()
|
|
112
112
|
} else {
|
|
113
113
|
const { id } = route
|
|
114
|
-
const htmlPath = id.replace(/pages\/(.*?)\.
|
|
115
|
-
|
|
114
|
+
const htmlPath = id.replace(/pages\/(.*?)\.(j|t)sx/, 'html/$1.html')
|
|
115
|
+
let distDir = config.vite.build.outDir
|
|
116
|
+
if (!isAbsolute(config.vite.build.outDir)) {
|
|
117
|
+
distDir = join(config.vite.root, distDir)
|
|
118
|
+
}
|
|
119
|
+
const htmlSource = readFileSync(join(distDir, htmlPath), 'utf8')
|
|
116
120
|
const htmlFunction = await createHtmlFunction(htmlSource, scope, config)
|
|
117
121
|
handler = (_, reply) => htmlFunction.call(reply)
|
|
118
122
|
}
|
package/server.js
CHANGED
|
@@ -39,7 +39,7 @@ export function prepareServer(server) {
|
|
|
39
39
|
})
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
export async function createRoutes (fromPromise, { param } = { param: /\[([
|
|
42
|
+
export async function createRoutes (fromPromise, { param } = { param: /\[([.\w]+\+?)\]/ }) {
|
|
43
43
|
const { default: from } = await fromPromise
|
|
44
44
|
const importPaths = Object.keys(from)
|
|
45
45
|
const promises = []
|
package/virtual/core.jsx
CHANGED
|
@@ -33,7 +33,7 @@ export function useServerAction(action, options = {}) {
|
|
|
33
33
|
return actionData[action]
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
export function AppRoute({
|
|
36
|
+
export function AppRoute({ ctxHydration, ctx, children }) {
|
|
37
37
|
// If running on the server, assume all data
|
|
38
38
|
// functions have already ran through the preHandler hook
|
|
39
39
|
if (isServer) {
|
|
@@ -100,7 +100,8 @@ export function AppRoute({ head, ctxHydration, ctx, children }) {
|
|
|
100
100
|
if (!ctx.firstRender && ctx.getMeta) {
|
|
101
101
|
const updateMeta = async () => {
|
|
102
102
|
const { getMeta } = await ctx.loader()
|
|
103
|
-
head
|
|
103
|
+
ctx.head = await getMeta(ctx)
|
|
104
|
+
ctxHydration.useHead.push(ctx.head)
|
|
104
105
|
}
|
|
105
106
|
waitResource(path, 'updateMeta', updateMeta)
|
|
106
107
|
}
|
package/virtual/create.jsx
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
|
+
import {
|
|
2
|
+
UnheadProvider as ClientUnheadProvider
|
|
3
|
+
} from '@unhead/react/client'
|
|
4
|
+
import {
|
|
5
|
+
UnheadProvider as ServerUnheadProvider
|
|
6
|
+
} from '@unhead/react/server?server'
|
|
7
|
+
|
|
1
8
|
import Root from '$app/root.jsx'
|
|
2
9
|
|
|
3
10
|
export default function create({ url, ...serverInit }) {
|
|
4
|
-
|
|
11
|
+
const UnheadProvider = import.meta.env.SSR
|
|
12
|
+
? ServerUnheadProvider
|
|
13
|
+
: ClientUnheadProvider
|
|
14
|
+
return (
|
|
15
|
+
<UnheadProvider value={serverInit.ctxHydration.useHead}>
|
|
16
|
+
<Root url={url} {...serverInit} />
|
|
17
|
+
</UnheadProvider>
|
|
18
|
+
)
|
|
5
19
|
}
|
package/virtual/mount.js
CHANGED
|
@@ -1,23 +1,27 @@
|
|
|
1
1
|
import { createRoot, hydrateRoot } from 'react-dom/client'
|
|
2
|
-
import Head from 'unihead/client'
|
|
3
2
|
import { hydrateRoutes } from '@fastify/react/client'
|
|
3
|
+
import { createHead } from '@unhead/react/client'
|
|
4
4
|
import routes from '$app/routes.js'
|
|
5
5
|
import create from '$app/create.jsx'
|
|
6
6
|
import * as context from '$app/context.js'
|
|
7
7
|
|
|
8
8
|
async function mountApp (...targets) {
|
|
9
9
|
const ctxHydration = await extendContext(window.route, context)
|
|
10
|
-
const head = new Head(window.route.head, window.document)
|
|
11
10
|
const resolvedRoutes = await hydrateRoutes(routes)
|
|
12
11
|
const routeMap = Object.fromEntries(
|
|
13
12
|
resolvedRoutes.map((route) => [route.path, route]),
|
|
14
13
|
)
|
|
14
|
+
const useHead = createHead()
|
|
15
|
+
ctxHydration.useHead = useHead
|
|
16
|
+
ctxHydration.useHead.push(window.route.head)
|
|
17
|
+
|
|
15
18
|
const app = create({
|
|
16
|
-
head,
|
|
17
19
|
ctxHydration,
|
|
18
20
|
routes: window.routes,
|
|
19
21
|
routeMap,
|
|
20
22
|
})
|
|
23
|
+
|
|
24
|
+
|
|
21
25
|
let mountTargetFound = false
|
|
22
26
|
for (const target of targets) {
|
|
23
27
|
const targetElem = document.querySelector(target)
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { createPath } from 'history'
|
|
2
|
+
import { useEffect } from 'react'
|
|
3
|
+
import { BrowserRouter, StaticRouter, useLocation } from 'react-router'
|
|
4
|
+
import { proxy } from 'valtio'
|
|
5
|
+
import { RouteContext, useRouteContext } from '@fastify/react/client'
|
|
6
|
+
import layouts from '$app/layouts.js'
|
|
7
|
+
import { waitFetch, waitResource } from '$app/resource.js'
|
|
8
|
+
|
|
9
|
+
export const isServer = import.meta.env.SSR
|
|
10
|
+
export const Router = isServer ? StaticRouter : BrowserRouter
|
|
11
|
+
|
|
12
|
+
let serverActionCounter = 0
|
|
13
|
+
|
|
14
|
+
export function createServerAction(name) {
|
|
15
|
+
return `/-/action/${name ?? serverActionCounter++}`
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function useServerAction(action, options = {}) {
|
|
19
|
+
if (import.meta.env.SSR) {
|
|
20
|
+
const { req, server } = useRouteContext()
|
|
21
|
+
req.route.actionData[action] = waitFetch(
|
|
22
|
+
`${server.serverURL}${action}`,
|
|
23
|
+
options,
|
|
24
|
+
req.fetchMap,
|
|
25
|
+
)
|
|
26
|
+
return req.route.actionData[action]
|
|
27
|
+
}
|
|
28
|
+
const { actionData } = useRouteContext()
|
|
29
|
+
if (actionData[action]) {
|
|
30
|
+
return actionData[action]
|
|
31
|
+
}
|
|
32
|
+
actionData[action] = waitFetch(action, options)
|
|
33
|
+
return actionData[action]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function AppRoute({ ctxHydration, ctx, children }) {
|
|
37
|
+
// If running on the server, assume all data
|
|
38
|
+
// functions have already ran through the preHandler hook
|
|
39
|
+
if (isServer) {
|
|
40
|
+
const Layout = layouts[ctxHydration.layout ?? 'default']
|
|
41
|
+
return (
|
|
42
|
+
<RouteContext.Provider
|
|
43
|
+
value={{
|
|
44
|
+
...ctx,
|
|
45
|
+
...ctxHydration,
|
|
46
|
+
state: isServer
|
|
47
|
+
? ctxHydration.state ?? {}
|
|
48
|
+
: proxy(ctxHydration.state ?? {}),
|
|
49
|
+
}}
|
|
50
|
+
>
|
|
51
|
+
<Layout>{children}</Layout>
|
|
52
|
+
</RouteContext.Provider>
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
// Note that on the client, window.route === ctxHydration
|
|
56
|
+
|
|
57
|
+
// Indicates whether or not this is a first render on the client
|
|
58
|
+
ctx.firstRender = window.route.firstRender
|
|
59
|
+
|
|
60
|
+
// If running on the client, the server context data
|
|
61
|
+
// is still available, hydrated from window.route
|
|
62
|
+
if (ctx.firstRender) {
|
|
63
|
+
ctx.data = window.route.data
|
|
64
|
+
ctx.head = window.route.head
|
|
65
|
+
} else {
|
|
66
|
+
ctx.data = undefined
|
|
67
|
+
ctx.head = undefined
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const location = useLocation()
|
|
71
|
+
const path = createPath(location)
|
|
72
|
+
|
|
73
|
+
// When the next route renders client-side,
|
|
74
|
+
// force it to execute all URMA hooks again
|
|
75
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: I'm inclined to believe you, Biome, but I'm not risking it.
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
window.route.firstRender = false
|
|
78
|
+
window.route.actionData = {}
|
|
79
|
+
}, [location])
|
|
80
|
+
|
|
81
|
+
// If we have a getData function registered for this route
|
|
82
|
+
if (!ctx.data && ctx.getData) {
|
|
83
|
+
try {
|
|
84
|
+
const { pathname, search } = location
|
|
85
|
+
// If not, fetch data from the JSON endpoint
|
|
86
|
+
ctx.data = waitFetch(`/-/data${pathname}${search}`)
|
|
87
|
+
} catch (status) {
|
|
88
|
+
// If it's an actual error...
|
|
89
|
+
if (status instanceof Error) {
|
|
90
|
+
ctx.error = status
|
|
91
|
+
}
|
|
92
|
+
// If it's just a promise (suspended state)
|
|
93
|
+
throw status
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Note that ctx.loader() at this point will resolve the
|
|
98
|
+
// memoized module, so there's barely any overhead
|
|
99
|
+
|
|
100
|
+
if (!ctx.firstRender && ctx.getMeta) {
|
|
101
|
+
const updateMeta = async () => {
|
|
102
|
+
const { getMeta } = await ctx.loader()
|
|
103
|
+
ctx.head = await getMeta(ctx)
|
|
104
|
+
ctxHydration.useHead.push(ctx.head)
|
|
105
|
+
}
|
|
106
|
+
waitResource(path, 'updateMeta', updateMeta)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (!ctx.firstRender && ctx.onEnter) {
|
|
110
|
+
const runOnEnter = async () => {
|
|
111
|
+
const { onEnter } = await ctx.loader()
|
|
112
|
+
const updatedData = await onEnter(ctx)
|
|
113
|
+
if (!ctx.data) {
|
|
114
|
+
ctx.data = {}
|
|
115
|
+
}
|
|
116
|
+
Object.assign(ctx.data, updatedData)
|
|
117
|
+
}
|
|
118
|
+
waitResource(path, 'onEnter', runOnEnter)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const Layout = layouts[ctx.layout ?? 'default']
|
|
122
|
+
|
|
123
|
+
return (
|
|
124
|
+
<RouteContext.Provider
|
|
125
|
+
value={{
|
|
126
|
+
...ctxHydration,
|
|
127
|
+
...ctx,
|
|
128
|
+
state: isServer
|
|
129
|
+
? ctxHydration.state ?? {}
|
|
130
|
+
: proxy(ctxHydration.state ?? {}),
|
|
131
|
+
}}
|
|
132
|
+
>
|
|
133
|
+
<Layout>{children}</Layout>
|
|
134
|
+
</RouteContext.Provider>
|
|
135
|
+
)
|
|
136
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import {
|
|
2
|
+
UnheadProvider as ClientUnheadProvider
|
|
3
|
+
} from '@unhead/react/client'
|
|
4
|
+
import {
|
|
5
|
+
UnheadProvider as ServerUnheadProvider
|
|
6
|
+
} from '@unhead/react/server?server'
|
|
7
|
+
|
|
8
|
+
import Root from '$app/root.jsx'
|
|
9
|
+
|
|
10
|
+
export default function create({ url, ...serverInit }) {
|
|
11
|
+
const UnheadProvider = import.meta.env.SSR
|
|
12
|
+
? ServerUnheadProvider
|
|
13
|
+
: ClientUnheadProvider
|
|
14
|
+
return (
|
|
15
|
+
<UnheadProvider value={serverInit.ctxHydration.useHead}>
|
|
16
|
+
<Root url={url} {...serverInit} />
|
|
17
|
+
</UnheadProvider>
|
|
18
|
+
)
|
|
19
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { lazy } from 'react'
|
|
2
|
+
|
|
3
|
+
const DefaultLayout = () => import('$app/layouts/default.tsx')
|
|
4
|
+
|
|
5
|
+
const appLayouts = import.meta.glob('/layouts/*.{jsx,tsx}')
|
|
6
|
+
|
|
7
|
+
if (
|
|
8
|
+
!Object.keys(appLayouts).some((path) =>
|
|
9
|
+
path.match(/\/layouts\/default\.(j|t)sx/),
|
|
10
|
+
)
|
|
11
|
+
) {
|
|
12
|
+
appLayouts['/layouts/default.tsx'] = DefaultLayout
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export default Object.fromEntries(
|
|
16
|
+
Object.keys(appLayouts).map((path) => {
|
|
17
|
+
const name = path.slice(9, -4)
|
|
18
|
+
return [name, lazy(appLayouts[path])]
|
|
19
|
+
}),
|
|
20
|
+
)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { createRoot, hydrateRoot } from 'react-dom/client'
|
|
2
|
+
import { hydrateRoutes } from '@fastify/react/client'
|
|
3
|
+
import { createHead } from '@unhead/react/client'
|
|
4
|
+
import routes from '$app/routes.js'
|
|
5
|
+
import create from '$app/create.jsx'
|
|
6
|
+
import * as context from '$app/context.js'
|
|
7
|
+
|
|
8
|
+
async function mountApp (...targets) {
|
|
9
|
+
const ctxHydration = await extendContext(window.route, context)
|
|
10
|
+
const resolvedRoutes = await hydrateRoutes(routes)
|
|
11
|
+
const routeMap = Object.fromEntries(
|
|
12
|
+
resolvedRoutes.map((route) => [route.path, route]),
|
|
13
|
+
)
|
|
14
|
+
const useHead = createHead()
|
|
15
|
+
ctxHydration.useHead = useHead
|
|
16
|
+
ctxHydration.useHead.push(window.route.head)
|
|
17
|
+
|
|
18
|
+
const app = create({
|
|
19
|
+
ctxHydration,
|
|
20
|
+
routes: window.routes,
|
|
21
|
+
routeMap,
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
let mountTargetFound = false
|
|
26
|
+
for (const target of targets) {
|
|
27
|
+
const targetElem = document.querySelector(target)
|
|
28
|
+
if (targetElem) {
|
|
29
|
+
mountTargetFound = true
|
|
30
|
+
if (ctxHydration.clientOnly) {
|
|
31
|
+
createRoot(targetElem).render(app)
|
|
32
|
+
} else {
|
|
33
|
+
hydrateRoot(targetElem, app)
|
|
34
|
+
}
|
|
35
|
+
break
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (!mountTargetFound) {
|
|
39
|
+
throw new Error(`No mount element found from provided list of targets: ${targets}`)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
mountApp('#root', 'main')
|
|
44
|
+
|
|
45
|
+
async function extendContext (ctx, {
|
|
46
|
+
// The route context initialization function
|
|
47
|
+
default: setter,
|
|
48
|
+
// We destructure state here just to discard it from extra
|
|
49
|
+
state,
|
|
50
|
+
// Other named exports from context.js
|
|
51
|
+
...extra
|
|
52
|
+
}) {
|
|
53
|
+
Object.assign(ctx, extra)
|
|
54
|
+
if (setter) {
|
|
55
|
+
await setter(ctx)
|
|
56
|
+
}
|
|
57
|
+
return ctx
|
|
58
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
const clientFetchMap = new Map()
|
|
2
|
+
const clientResourceMap = new Map()
|
|
3
|
+
|
|
4
|
+
export function waitResource(
|
|
5
|
+
path,
|
|
6
|
+
id,
|
|
7
|
+
promise,
|
|
8
|
+
resourceMap = clientResourceMap,
|
|
9
|
+
) {
|
|
10
|
+
const resourceId = `${path}:${id}`
|
|
11
|
+
const loaderStatus = resourceMap.get(resourceId)
|
|
12
|
+
if (loaderStatus) {
|
|
13
|
+
if (loaderStatus.error) {
|
|
14
|
+
throw loaderStatus.error
|
|
15
|
+
}
|
|
16
|
+
if (loaderStatus.suspended) {
|
|
17
|
+
throw loaderStatus.promise
|
|
18
|
+
}
|
|
19
|
+
resourceMap.delete(resourceId)
|
|
20
|
+
|
|
21
|
+
return loaderStatus.result
|
|
22
|
+
}
|
|
23
|
+
const loader = {
|
|
24
|
+
suspended: true,
|
|
25
|
+
error: null,
|
|
26
|
+
result: null,
|
|
27
|
+
promise: null,
|
|
28
|
+
}
|
|
29
|
+
loader.promise = promise()
|
|
30
|
+
.then((result) => {
|
|
31
|
+
loader.result = result
|
|
32
|
+
})
|
|
33
|
+
.catch((loaderError) => {
|
|
34
|
+
loader.error = loaderError
|
|
35
|
+
})
|
|
36
|
+
.finally(() => {
|
|
37
|
+
loader.suspended = false
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
resourceMap.set(resourceId, loader)
|
|
41
|
+
|
|
42
|
+
return waitResource(path, id)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function waitFetch(path, options = {}, fetchMap = clientFetchMap) {
|
|
46
|
+
const loaderStatus = fetchMap.get(path)
|
|
47
|
+
if (loaderStatus) {
|
|
48
|
+
if (loaderStatus.error || loaderStatus.data?.statusCode === 500) {
|
|
49
|
+
if (loaderStatus.data?.statusCode === 500) {
|
|
50
|
+
throw new Error(loaderStatus.data.message)
|
|
51
|
+
}
|
|
52
|
+
throw loaderStatus.error
|
|
53
|
+
}
|
|
54
|
+
if (loaderStatus.suspended) {
|
|
55
|
+
throw loaderStatus.promise
|
|
56
|
+
}
|
|
57
|
+
fetchMap.delete(path)
|
|
58
|
+
|
|
59
|
+
return loaderStatus.data
|
|
60
|
+
}
|
|
61
|
+
const loader = {
|
|
62
|
+
suspended: true,
|
|
63
|
+
error: null,
|
|
64
|
+
data: null,
|
|
65
|
+
promise: null,
|
|
66
|
+
}
|
|
67
|
+
loader.promise = fetch(path, options)
|
|
68
|
+
.then((response) => response.json())
|
|
69
|
+
.then((loaderData) => {
|
|
70
|
+
loader.data = loaderData
|
|
71
|
+
})
|
|
72
|
+
.catch((loaderError) => {
|
|
73
|
+
loader.error = loaderError
|
|
74
|
+
})
|
|
75
|
+
.finally(() => {
|
|
76
|
+
loader.suspended = false
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
fetchMap.set(path, loader)
|
|
80
|
+
|
|
81
|
+
return waitFetch(path, options, fetchMap)
|
|
82
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { Suspense } from 'react'
|
|
2
|
+
import { Route, Routes } from 'react-router'
|
|
3
|
+
import { AppRoute, Router } from '$app/core.tsx'
|
|
4
|
+
|
|
5
|
+
export default function Root({ url, routes, head, ctxHydration, routeMap }) {
|
|
6
|
+
return (
|
|
7
|
+
<Suspense>
|
|
8
|
+
<Router location={url}>
|
|
9
|
+
<Routes>
|
|
10
|
+
{routes.map(({ path, component: Component }) => (
|
|
11
|
+
<Route
|
|
12
|
+
key={path}
|
|
13
|
+
path={path}
|
|
14
|
+
element={
|
|
15
|
+
<AppRoute
|
|
16
|
+
head={head}
|
|
17
|
+
ctxHydration={ctxHydration}
|
|
18
|
+
ctx={routeMap[path]}
|
|
19
|
+
>
|
|
20
|
+
<Component />
|
|
21
|
+
</AppRoute>
|
|
22
|
+
}
|
|
23
|
+
/>
|
|
24
|
+
))}
|
|
25
|
+
</Routes>
|
|
26
|
+
</Router>
|
|
27
|
+
</Suspense>
|
|
28
|
+
)
|
|
29
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export default import.meta.glob('/pages/**/*.{jsx,tsx}')
|