@fastify/react 1.0.2 → 1.1.0-beta.1

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 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.2",
6
+ "version": "1.1.0-beta.1",
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.0.5"
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
- name: 'vite-plugin-fastify-react',
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
- const distDir = join(root, this.environment.config.build.outDir)
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\/(.*)\.jsx$/, 'html/$1.html')
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 (!/\.((mc)?ts)|((mc)?js)|(jsx)$/.test(virtual)) {
46
- virtual += '.js'
47
- }
48
- if (!virtualModules.includes(virtual)) {
59
+ if (!virtualModules.includes(virtual) && !virtualModulesTS.includes(virtual)) {
49
60
  return
50
61
  }
51
- const code = readFileSync(resolve(virtualRoot, virtual), 'utf8')
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, virtual) {
71
- if (!virtualModules.includes(virtual)) {
99
+ function loadVirtualModuleOverride (viteProjectRoot, virtualInput) {
100
+ let virtual = virtualInput
101
+ if (!virtualModules.includes(virtual) && !virtualModulesTS.includes(virtual)) {
72
102
  return
73
103
  }
74
- const overridePath = resolve(viteProjectRoot, virtual)
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 Head from 'unihead'
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, body) {
120
- context.head = new Head(context.head).render()
121
- return `${
122
- templates.beforeElement(context)
123
- }${
124
- templates.afterElement(context)
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 templates.beforeElement(context)
137
+ yield await transformHtmlTemplate(
138
+ context.useHead,
139
+ templates.beforeElement(context)
140
+ )
141
+
135
142
  for await (const chunk of body) {
136
- yield chunk
143
+ yield await transformHtmlTemplate(
144
+ context.useHead,
145
+ chunk.toString()
146
+ )
137
147
  }
138
- yield templates.afterElement(context)
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\/(.*?)\.jsx/, 'html/$1.html')
115
- const htmlSource = readFileSync(join(config.vite.root, config.vite.build.outDir, htmlPath), 'utf8')
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: /\[([\.\w]+\+?)\]/ }) {
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({ head, ctxHydration, ctx, children }) {
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.update(await getMeta(ctx))
103
+ ctx.head = await getMeta(ctx)
104
+ ctxHydration.useHead.push(ctx.head)
104
105
  }
105
106
  waitResource(path, 'updateMeta', updateMeta)
106
107
  }
@@ -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
- return <Root url={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
+ )
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,4 @@
1
+ // This file serves as a placeholder
2
+ // if no context.js file is provided
3
+
4
+ export default () => {}
@@ -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,7 @@
1
+ import { createRoutes } from '@fastify/react/server'
2
+
3
+ export default {
4
+ routes: createRoutes(import('$app/routes.ts')),
5
+ create: import('$app/create.tsx'),
6
+ context: import('$app/context.ts'),
7
+ }
@@ -0,0 +1,8 @@
1
+ // This file serves as a placeholder
2
+ // if no layouts/default.jsx file is provided
3
+
4
+ import { Suspense } from 'react'
5
+
6
+ export default function Layout({ children }) {
7
+ return <Suspense>{children}</Suspense>
8
+ }
@@ -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}')