@nifrajs/web-solid 5.5.1 → 5.7.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/CHANGELOG.md +135 -0
- package/dist/client.js +2 -2
- package/dist/client.js.map +1 -1
- package/dist/content.d.ts +1 -1
- package/dist/content.js +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +42 -3
- package/dist/index.js.map +1 -1
- package/dist/keep-alive-view.d.ts +35 -0
- package/dist/keep-alive-view.d.ts.map +1 -0
- package/dist/keep-alive-view.js +135 -0
- package/dist/keep-alive-view.js.map +1 -0
- package/dist/keep-alive.d.ts +25 -0
- package/dist/keep-alive.d.ts.map +1 -0
- package/dist/keep-alive.js +51 -0
- package/dist/keep-alive.js.map +1 -0
- package/dist/mdx.d.ts.map +1 -1
- package/dist/mdx.js +5 -16
- package/dist/mdx.js.map +1 -1
- package/dist/page-visibility.d.ts +28 -0
- package/dist/page-visibility.d.ts.map +1 -0
- package/dist/page-visibility.js +42 -0
- package/dist/page-visibility.js.map +1 -0
- package/dist/ppr-client.d.ts +9 -0
- package/dist/ppr-client.d.ts.map +1 -0
- package/dist/ppr-client.js +63 -0
- package/dist/ppr-client.js.map +1 -0
- package/dist/ppr.d.ts +37 -0
- package/dist/ppr.d.ts.map +1 -0
- package/dist/ppr.js +61 -0
- package/dist/ppr.js.map +1 -0
- package/dist/query.d.ts +4 -3
- package/dist/query.d.ts.map +1 -1
- package/dist/query.js +7 -8
- package/dist/query.js.map +1 -1
- package/dist/route-props.d.ts +5 -2
- package/dist/route-props.d.ts.map +1 -1
- package/dist/route-props.js +16 -7
- package/dist/route-props.js.map +1 -1
- package/dist/router.d.ts +3 -2
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +7 -3
- package/dist/router.js.map +1 -1
- package/dist/server-components/Outlet.jsx +1 -1
- package/dist/server-components/Outlet.jsx.map +1 -1
- package/package.json +25 -7
- package/src/client.ts +2 -2
- package/src/content.ts +1 -1
- package/src/index.ts +50 -5
- package/src/keep-alive-view.ts +202 -0
- package/src/keep-alive.ts +76 -0
- package/src/mdx.ts +5 -9
- package/src/page-visibility.ts +53 -0
- package/src/ppr-client.ts +77 -0
- package/src/ppr.ts +86 -0
- package/src/query.ts +13 -8
- package/src/route-props.ts +16 -6
- package/src/router.ts +8 -3
- package/src/server-components/Outlet.tsx +1 -1
package/src/content.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `@nifrajs/web-solid/content` - render pre-rendered HTML (e.g. a `@nifrajs/content` collection entry's
|
|
3
3
|
* `.html`) into the DOM. Solid sets `innerHTML` reactively on a `<Dynamic>` element. Pairs with content
|
|
4
|
-
* collections: `<Content html={entry.html}
|
|
4
|
+
* collections: `<Content html={trustHtml(entry.html)} />`, with `trustHtml` from `@nifrajs/web`. No JSX (`createComponent`/`Dynamic`), so it builds with
|
|
5
5
|
* plain `tsc`.
|
|
6
6
|
*
|
|
7
7
|
* Security: injects raw HTML, so `html` MUST be trusted (your own Markdown/content, rendered at
|
package/src/index.ts
CHANGED
|
@@ -4,20 +4,22 @@
|
|
|
4
4
|
* hydration lives in `@nifrajs/web-solid/client` (Solid's browser build), and the Solid Babel
|
|
5
5
|
* Bun-plugin in `@nifrajs/web-solid/plugin`.
|
|
6
6
|
*/
|
|
7
|
-
import type { RenderAdapter } from "@nifrajs/web"
|
|
8
|
-
import { sharedConfig } from "solid-js"
|
|
7
|
+
import type { RenderAdapter, RenderProps, RenderStreamOptions, RouterState } from "@nifrajs/web"
|
|
8
|
+
import { type JSX, sharedConfig } from "solid-js"
|
|
9
9
|
import {
|
|
10
10
|
generateHydrationScript,
|
|
11
11
|
renderToStream as solidRenderToStream,
|
|
12
12
|
renderToString as solidRenderToString,
|
|
13
13
|
} from "solid-js/web"
|
|
14
14
|
import { compose } from "./compose.ts"
|
|
15
|
+
import { documentTree, KeepAliveView } from "./keep-alive-view.ts"
|
|
15
16
|
|
|
16
17
|
const HYDRATION_HEAD = generateHydrationScript()
|
|
17
18
|
|
|
18
19
|
// An unset NODE_ENV counts as production, as for the framework's own error pages.
|
|
19
20
|
const showsErrors = (): boolean => {
|
|
20
|
-
|
|
21
|
+
// No `?.`: a bundler's NODE_ENV define replaces only the plain member read.
|
|
22
|
+
const nodeEnv = typeof process === "undefined" ? undefined : process.env.NODE_ENV
|
|
21
23
|
return nodeEnv === "development" || nodeEnv === "test"
|
|
22
24
|
}
|
|
23
25
|
|
|
@@ -52,6 +54,27 @@ function redactingErrors<T>(app: () => T): () => T {
|
|
|
52
54
|
}
|
|
53
55
|
}
|
|
54
56
|
|
|
57
|
+
const noRef = (): undefined => undefined
|
|
58
|
+
|
|
59
|
+
/** The chain as the browser's keep-alive view renders it, in an app with a `keepAlive` route. */
|
|
60
|
+
function documentView(chain: readonly unknown[], props: RenderProps, slots: readonly number[]) {
|
|
61
|
+
const state: RouterState = { routeId: "", params: {}, path: "", data: props.data, pending: false }
|
|
62
|
+
const tree = documentTree(chain, state, slots)
|
|
63
|
+
return (): JSX.Element =>
|
|
64
|
+
KeepAliveView({
|
|
65
|
+
tree: () => tree,
|
|
66
|
+
shown: props,
|
|
67
|
+
mount: { propsOf: () => props, chainOf: () => chain, entry: noRef, page: noRef },
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const appOf = (chain: readonly unknown[], props: RenderProps, options?: RenderStreamOptions) =>
|
|
72
|
+
redactingErrors(
|
|
73
|
+
options?.slots === undefined
|
|
74
|
+
? compose(chain, props)
|
|
75
|
+
: documentView(chain, props, options.slots),
|
|
76
|
+
)
|
|
77
|
+
|
|
55
78
|
/** The Solid server render adapter - pass to @nifrajs/web's `renderPage`. */
|
|
56
79
|
export const solidAdapter: RenderAdapter = {
|
|
57
80
|
// Synchronous one-pass render for non-deferred pages (renderPage's buffered fast path). Solid's
|
|
@@ -60,11 +83,33 @@ export const solidAdapter: RenderAdapter = {
|
|
|
60
83
|
// streaming machinery - the heaviest of the five renderers on Bun. A page that defer()s/Suspends
|
|
61
84
|
// takes `renderToStream` below (progressive resolution needs it).
|
|
62
85
|
renderToString(chain, props, options) {
|
|
63
|
-
const app =
|
|
86
|
+
const app = appOf(chain, props, options)
|
|
64
87
|
return options?.nonce === undefined
|
|
65
88
|
? solidRenderToString(app)
|
|
66
89
|
: solidRenderToString(app, { nonce: options.nonce })
|
|
67
90
|
},
|
|
91
|
+
// A partially prerendered hole's HTML: its own root under `renderId`, so its hydration keys and
|
|
92
|
+
// `createUniqueId`s never collide with the page's, and its JSX runs inside the render that keys it.
|
|
93
|
+
// Without a prefix (a fallback, never hydrated) it carries no hydration keys at all.
|
|
94
|
+
renderFragment(ui, options) {
|
|
95
|
+
// biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: a hole's render returns Solid output; the seam erases it to unknown.
|
|
96
|
+
const view = () => ui() as JSX.Element
|
|
97
|
+
// `renderToString` leaves its context in Solid's shared config; a page mid-render keeps its own.
|
|
98
|
+
const page = sharedConfig.context
|
|
99
|
+
try {
|
|
100
|
+
return options.idPrefix === undefined
|
|
101
|
+
? solidRenderToString(() => {
|
|
102
|
+
// On the root context, so nodes resolved after `view` returns are unkeyed too.
|
|
103
|
+
if (sharedConfig.context !== undefined)
|
|
104
|
+
Reflect.set(sharedConfig.context, "noHydrate", true)
|
|
105
|
+
return view()
|
|
106
|
+
})
|
|
107
|
+
: solidRenderToString(view, { renderId: options.idPrefix })
|
|
108
|
+
} finally {
|
|
109
|
+
// Unset included: `context` is an optional property.
|
|
110
|
+
Reflect.set(sharedConfig, "context", page)
|
|
111
|
+
}
|
|
112
|
+
},
|
|
68
113
|
renderToStream(chain, props, options) {
|
|
69
114
|
// Solid's `renderToStream` streams `Uint8Array` chunks into a Web `WritableStream` via
|
|
70
115
|
// `pipeTo` (fire-and-forget - returns void); pipe into a TransformStream and hand back the
|
|
@@ -72,7 +117,7 @@ export const solidAdapter: RenderAdapter = {
|
|
|
72
117
|
// <head>) seeds client hydration. A render failure errors `ts.readable`, which `renderPage`
|
|
73
118
|
// surfaces on the response body. The nonce reaches the resource and boundary scripts Solid streams.
|
|
74
119
|
const ts = new TransformStream<Uint8Array, Uint8Array>()
|
|
75
|
-
const app =
|
|
120
|
+
const app = appOf(chain, props, options)
|
|
76
121
|
const stream =
|
|
77
122
|
options?.nonce === undefined
|
|
78
123
|
? solidRenderToStream(app)
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The view of an app with a `keepAlive` route: the page on screen and every kept page as a tree of
|
|
3
|
+
* slots (`slotTree` from `@nifrajs/web/keep-alive`), grouped by the layouts they share. The server
|
|
4
|
+
* renders it with the one page a document shows and the browser hydrates it, so both sides create the
|
|
5
|
+
* same components in the same order and Solid's hydration keys line up.
|
|
6
|
+
*
|
|
7
|
+
* A slot is keyed by its node's key, not the node object: a node is replaced whenever its state or its
|
|
8
|
+
* visibility changes, and keying on it would remount the page. A slot reads its node through a memo,
|
|
9
|
+
* so a hidden page, whose node the tree keeps, updates nothing while it is hidden.
|
|
10
|
+
*/
|
|
11
|
+
import type { RenderProps, RouterState } from "@nifrajs/web"
|
|
12
|
+
import type { SlotNode } from "@nifrajs/web/keep-alive"
|
|
13
|
+
import {
|
|
14
|
+
type Accessor,
|
|
15
|
+
type Component,
|
|
16
|
+
createComponent,
|
|
17
|
+
createComputed,
|
|
18
|
+
createMemo,
|
|
19
|
+
For,
|
|
20
|
+
type JSX,
|
|
21
|
+
on,
|
|
22
|
+
untrack,
|
|
23
|
+
} from "solid-js"
|
|
24
|
+
import { Dynamic } from "solid-js/web"
|
|
25
|
+
import { PageContext, type PageSlot } from "./page-visibility.ts"
|
|
26
|
+
import { RenderPropsContext, SearchContext } from "./router.ts"
|
|
27
|
+
|
|
28
|
+
/** What the view renders with: one per mount, on the server or in the browser. */
|
|
29
|
+
export interface KeepAliveViewMount {
|
|
30
|
+
/** The props of a state, as getters over it. */
|
|
31
|
+
readonly propsOf: (state: Accessor<RouterState>) => RenderProps
|
|
32
|
+
readonly chainOf: (state: RouterState) => readonly unknown[]
|
|
33
|
+
readonly entry: (key: string) => ((element: Element) => void) | undefined
|
|
34
|
+
readonly page: (key: string) => ((element: Element) => void) | undefined
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface KeepAliveViewProps {
|
|
38
|
+
readonly tree: Accessor<readonly SlotNode[]>
|
|
39
|
+
/** The props of the state on screen, which the layouts every page shares render with. */
|
|
40
|
+
readonly shown: RenderProps
|
|
41
|
+
readonly mount: KeepAliveViewMount
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
type LayoutProps = {
|
|
45
|
+
readonly data: unknown
|
|
46
|
+
readonly boundaries?: RenderProps["boundaries"] | undefined
|
|
47
|
+
readonly children?: JSX.Element
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const EMPTY_SEARCH: Readonly<Record<string, unknown>> = Object.freeze({})
|
|
51
|
+
|
|
52
|
+
// The providers `compose` puts around a chain, for one slot's props.
|
|
53
|
+
const withRouteContext = (props: RenderProps, children: () => JSX.Element): JSX.Element =>
|
|
54
|
+
createComponent(SearchContext.Provider, {
|
|
55
|
+
value: () => props.search ?? EMPTY_SEARCH,
|
|
56
|
+
get children() {
|
|
57
|
+
return createComponent(RenderPropsContext.Provider, {
|
|
58
|
+
value: props,
|
|
59
|
+
get children() {
|
|
60
|
+
return children()
|
|
61
|
+
},
|
|
62
|
+
})
|
|
63
|
+
},
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
// The page's own chain from `depth` on: an `_error` boundary, then the page.
|
|
67
|
+
const pageChain = (chain: readonly unknown[], depth: number, props: RenderProps): JSX.Element => {
|
|
68
|
+
if (depth >= chain.length - 1)
|
|
69
|
+
// biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: a chain holds the route's modules' default exports, its last entry the page component
|
|
70
|
+
return createComponent(chain[chain.length - 1] as Component<RenderProps>, props)
|
|
71
|
+
// biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: an entry between the layouts and the page is the `_error` boundary component
|
|
72
|
+
return createComponent(chain[depth] as Component<LayoutProps>, {
|
|
73
|
+
get data() {
|
|
74
|
+
return props.layoutData?.[depth] ?? null
|
|
75
|
+
},
|
|
76
|
+
get boundaries() {
|
|
77
|
+
return props.boundaries
|
|
78
|
+
},
|
|
79
|
+
get children() {
|
|
80
|
+
return pageChain(chain, depth + 1, props)
|
|
81
|
+
},
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const sameKeys = (a: readonly string[], b: readonly string[]): boolean =>
|
|
86
|
+
a.length === b.length && a.every((key, i) => key === b[i])
|
|
87
|
+
|
|
88
|
+
function Level(props: {
|
|
89
|
+
readonly nodes: Accessor<readonly SlotNode[]>
|
|
90
|
+
readonly mount: KeepAliveViewMount
|
|
91
|
+
}): JSX.Element {
|
|
92
|
+
const keys = createMemo(() => props.nodes().map((node) => node.key), [], { equals: sameKeys })
|
|
93
|
+
return createComponent(For, {
|
|
94
|
+
get each() {
|
|
95
|
+
return keys()
|
|
96
|
+
},
|
|
97
|
+
children: (key: string) =>
|
|
98
|
+
createComponent(Slot, { key, nodes: props.nodes, mount: props.mount }),
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function Slot(props: {
|
|
103
|
+
readonly key: string
|
|
104
|
+
readonly nodes: Accessor<readonly SlotNode[]>
|
|
105
|
+
readonly mount: KeepAliveViewMount
|
|
106
|
+
}): JSX.Element {
|
|
107
|
+
const { key, mount } = props
|
|
108
|
+
const find = (): SlotNode | undefined => props.nodes().find((node) => node.key === key)
|
|
109
|
+
const first = untrack(find)
|
|
110
|
+
if (first === undefined) throw new Error(`[nifra/web-solid] no slot node "${key}"`)
|
|
111
|
+
// The last node with this key stays while the slot is being removed with it.
|
|
112
|
+
const node = createMemo((last: SlotNode) => find() ?? last, first)
|
|
113
|
+
const state = createMemo(() => node().state)
|
|
114
|
+
const hidden = createMemo(() => node().hidden)
|
|
115
|
+
const slotProps = mount.propsOf(state)
|
|
116
|
+
// A key keeps its depth, its wrappers and its chain: a page's key is one instance of one route, a
|
|
117
|
+
// layout's key the layout every page under it shares.
|
|
118
|
+
const { depth } = first
|
|
119
|
+
const chain = mount.chainOf(first.state)
|
|
120
|
+
const content = (): JSX.Element =>
|
|
121
|
+
first.children === undefined
|
|
122
|
+
? pageChain(chain, depth, slotProps)
|
|
123
|
+
: // biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: a chain entry above the page is a layout module's default export, a layout component
|
|
124
|
+
createComponent(chain[depth] as Component<LayoutProps>, {
|
|
125
|
+
get data() {
|
|
126
|
+
return slotProps.layoutData?.[depth] ?? null
|
|
127
|
+
},
|
|
128
|
+
get boundaries() {
|
|
129
|
+
return slotProps.boundaries
|
|
130
|
+
},
|
|
131
|
+
get children() {
|
|
132
|
+
return createComponent(Level, { nodes: () => node().children ?? [], mount })
|
|
133
|
+
},
|
|
134
|
+
})
|
|
135
|
+
if (!first.wrapped) return content()
|
|
136
|
+
|
|
137
|
+
const slot: PageSlot = { hidden, hide: new Set(), show: new Set() }
|
|
138
|
+
// A computation, not an effect: it runs as the tree changes, before the mount parks the page.
|
|
139
|
+
createComputed(
|
|
140
|
+
on(hidden, (now, before) => {
|
|
141
|
+
if (before === undefined || now === before) return
|
|
142
|
+
for (const callback of [...(now ? slot.hide : slot.show)]) callback()
|
|
143
|
+
}),
|
|
144
|
+
)
|
|
145
|
+
return createComponent(Dynamic, {
|
|
146
|
+
component: "slot",
|
|
147
|
+
"data-nifra-entry": "",
|
|
148
|
+
ref: mount.entry(key),
|
|
149
|
+
get children() {
|
|
150
|
+
return createComponent(Dynamic, {
|
|
151
|
+
component: "slot",
|
|
152
|
+
"data-nifra-page": "",
|
|
153
|
+
ref: mount.page(key),
|
|
154
|
+
get children() {
|
|
155
|
+
return createComponent(PageContext.Provider, {
|
|
156
|
+
value: slot,
|
|
157
|
+
get children() {
|
|
158
|
+
return withRouteContext(slotProps, content)
|
|
159
|
+
},
|
|
160
|
+
})
|
|
161
|
+
},
|
|
162
|
+
})
|
|
163
|
+
},
|
|
164
|
+
})
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** The keep-alive view. */
|
|
168
|
+
export function KeepAliveView(props: KeepAliveViewProps): JSX.Element {
|
|
169
|
+
return withRouteContext(props.shown, () =>
|
|
170
|
+
createComponent(Level, { nodes: props.tree, mount: props.mount }),
|
|
171
|
+
)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The tree of a server render: the chain as one path of slot nodes, the entries at `slots` inside
|
|
176
|
+
* the slot wrappers, which is what the browser's first render builds from the same state.
|
|
177
|
+
*/
|
|
178
|
+
export function documentTree(
|
|
179
|
+
chain: readonly unknown[],
|
|
180
|
+
state: RouterState,
|
|
181
|
+
slots: readonly number[],
|
|
182
|
+
): readonly SlotNode[] {
|
|
183
|
+
const page = chain.length - 1
|
|
184
|
+
let node: SlotNode = {
|
|
185
|
+
key: "\0page",
|
|
186
|
+
depth: page,
|
|
187
|
+
state,
|
|
188
|
+
hidden: false,
|
|
189
|
+
wrapped: slots.includes(page),
|
|
190
|
+
}
|
|
191
|
+
for (let depth = page - 1; depth >= 0; depth--) {
|
|
192
|
+
node = {
|
|
193
|
+
key: `\0layout${depth}`,
|
|
194
|
+
depth,
|
|
195
|
+
state,
|
|
196
|
+
hidden: false,
|
|
197
|
+
wrapped: slots.includes(depth),
|
|
198
|
+
children: [node],
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return [node]
|
|
202
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @nifrajs/web-solid/keep-alive - the client mount of an app with a `keepAlive` route, which the
|
|
3
|
+
* generated client entry imports in place of `@nifrajs/web-solid/client` (whose exports it carries).
|
|
4
|
+
*
|
|
5
|
+
* One root renders the page on screen and every page the router keeps (`KeepAliveView`), grouped by
|
|
6
|
+
* the layouts they share; the DOM half takes a hidden page out of the document. A hidden page keeps
|
|
7
|
+
* running: `onPageHide`, `onPageShow` and `usePageVisibility` (from `@nifrajs/web-solid/router`) let
|
|
8
|
+
* it pause. Unlike the plain mount, a route change does not remount the root: Solid updates the tree
|
|
9
|
+
* in place, synchronously, so the DOM half runs right after each publish.
|
|
10
|
+
*/
|
|
11
|
+
import type { MountRouterOptions, RouterState } from "@nifrajs/web"
|
|
12
|
+
import {
|
|
13
|
+
createSlotParking,
|
|
14
|
+
type KeepAliveRouter,
|
|
15
|
+
type KeepAliveTable,
|
|
16
|
+
type SlotNode,
|
|
17
|
+
slotTree,
|
|
18
|
+
} from "@nifrajs/web/keep-alive"
|
|
19
|
+
import { createMemo, createSignal } from "solid-js"
|
|
20
|
+
import { hydrate as solidHydrate } from "solid-js/web"
|
|
21
|
+
import { setMountedRouter } from "./fetcher.ts"
|
|
22
|
+
import { KeepAliveView, type KeepAliveViewMount } from "./keep-alive-view.ts"
|
|
23
|
+
import { routeProps } from "./route-props.ts"
|
|
24
|
+
|
|
25
|
+
export * from "./client.ts"
|
|
26
|
+
export { onPageHide, onPageShow, usePageVisibility } from "./page-visibility.ts"
|
|
27
|
+
|
|
28
|
+
/** `mountRouter`'s options in a keep-alive app: the generated entry adds where the wrappers go. */
|
|
29
|
+
export interface KeepAliveMountOptions extends MountRouterOptions {
|
|
30
|
+
readonly router: KeepAliveRouter
|
|
31
|
+
readonly keepAlive: KeepAliveTable
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Hydrate the keep-alive view. Its first render matches the server's (the same view, with the slot
|
|
36
|
+
* wrappers the server rendered), so hydration claims the document as it is.
|
|
37
|
+
*/
|
|
38
|
+
export function mountRouter(options: KeepAliveMountOptions): void {
|
|
39
|
+
const { router, routes, searchSchemas, container, keepAlive: table } = options
|
|
40
|
+
const matchChains = options.matchChains ?? {}
|
|
41
|
+
setMountedRouter(router)
|
|
42
|
+
const parking = createSlotParking()
|
|
43
|
+
let committed: readonly SlotNode[] | undefined
|
|
44
|
+
router.beforePublish((next) => parking.before(slotTree(next, matchChains, table, committed)))
|
|
45
|
+
|
|
46
|
+
let current: readonly SlotNode[] = []
|
|
47
|
+
let publish: ((state: RouterState) => void) | undefined
|
|
48
|
+
// biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: the generated entry passes the element the server rendered the app into
|
|
49
|
+
solidHydrate(() => {
|
|
50
|
+
const [state, setState] = createSignal(router.snapshot())
|
|
51
|
+
publish = (next) => setState(() => next)
|
|
52
|
+
const tree = createMemo(() => {
|
|
53
|
+
current = slotTree(state(), matchChains, table, current)
|
|
54
|
+
return current
|
|
55
|
+
})
|
|
56
|
+
const mount: KeepAliveViewMount = {
|
|
57
|
+
propsOf: (of) => routeProps(of, searchSchemas, matchChains, (read) => createMemo(read)),
|
|
58
|
+
chainOf: (of) => routes[of.routeId] ?? [],
|
|
59
|
+
entry: parking.entry,
|
|
60
|
+
page: parking.page,
|
|
61
|
+
}
|
|
62
|
+
return KeepAliveView({ tree, shown: mount.propsOf(state), mount })
|
|
63
|
+
// biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: the generated entry passes the element the server rendered the app into
|
|
64
|
+
}, container as Element)
|
|
65
|
+
// Solid has updated the document by the time a publish returns: park what it hid, restore what it
|
|
66
|
+
// showed again.
|
|
67
|
+
const after = (): void => {
|
|
68
|
+
committed = current
|
|
69
|
+
parking.after(current)
|
|
70
|
+
}
|
|
71
|
+
after()
|
|
72
|
+
router.subscribe(() => {
|
|
73
|
+
publish?.(router.snapshot())
|
|
74
|
+
after()
|
|
75
|
+
})
|
|
76
|
+
}
|
package/src/mdx.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import { transformAsync } from "@babel/core"
|
|
10
10
|
// @ts-expect-error - no type declarations published
|
|
11
11
|
import presetTypeScript from "@babel/preset-typescript"
|
|
12
|
-
import { normalizeFilePath, rewriteSsrImports } from "@nifrajs/web/plugins/kit"
|
|
12
|
+
import { normalizeFilePath, requirePeer, rewriteSsrImports } from "@nifrajs/web/plugins/kit"
|
|
13
13
|
// @ts-expect-error - no type declarations published
|
|
14
14
|
import presetSolid from "babel-preset-solid"
|
|
15
15
|
import type { BunPlugin } from "bun"
|
|
@@ -27,14 +27,10 @@ export function solidMdxBunPlugin(generate: "dom" | "ssr"): BunPlugin {
|
|
|
27
27
|
return {
|
|
28
28
|
name: `nifra-solid-mdx-${generate}`,
|
|
29
29
|
async setup(build) {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
}
|
|
34
|
-
throw new Error(
|
|
35
|
-
"@nifrajs/web-solid/mdx: compiling `.mdx` needs the `@mdx-js/mdx` package - install it (`bun add @mdx-js/mdx`).",
|
|
36
|
-
)
|
|
37
|
-
}
|
|
30
|
+
const { compile } = await requirePeer<MdxCompiler>(MDX_MODULE, {
|
|
31
|
+
feature: "Solid MDX support",
|
|
32
|
+
install: "bun add @mdx-js/mdx",
|
|
33
|
+
})
|
|
38
34
|
build.onLoad({ filter: /\.mdx(\?|$)/ }, async (args) => {
|
|
39
35
|
const path = normalizeFilePath(args.path)
|
|
40
36
|
// 1. MDX → JSX, with intrinsics provided by the Solid runtime (rendered via `<Dynamic>`).
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a page is on screen, for a `keepAlive` page that stays mounted while it is hidden. Pages read
|
|
3
|
+
* it through `@nifrajs/web-solid/router`; the keep-alive view provides it per kept slot.
|
|
4
|
+
*/
|
|
5
|
+
import { type Accessor, type Context, createContext, onCleanup, useContext } from "solid-js"
|
|
6
|
+
|
|
7
|
+
/** What one kept slot provides: whether it is hidden, and the callbacks it runs as that changes. */
|
|
8
|
+
export interface PageSlot {
|
|
9
|
+
readonly hidden: Accessor<boolean>
|
|
10
|
+
readonly hide: Set<() => void>
|
|
11
|
+
readonly show: Set<() => void>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// A `globalThis` singleton for the same reason as the router's contexts (see router.ts).
|
|
15
|
+
const PAGE_CONTEXT_SLOT = Symbol.for("nifra.web-solid.page-context")
|
|
16
|
+
// biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: only this module writes the registry key, and it writes a page context
|
|
17
|
+
const pageContextSlot = globalThis as { [PAGE_CONTEXT_SLOT]?: Context<PageSlot | undefined> }
|
|
18
|
+
export const PageContext = pageContextSlot[PAGE_CONTEXT_SLOT] ?? createContext<PageSlot>()
|
|
19
|
+
pageContextSlot[PAGE_CONTEXT_SLOT] = PageContext
|
|
20
|
+
|
|
21
|
+
const ALWAYS: Accessor<boolean> = () => true
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Whether the page this component renders in is on screen, as an accessor: `false` while it is kept
|
|
25
|
+
* hidden behind another page, where it keeps running. Always `true` on the server and on a page that
|
|
26
|
+
* does not keep alive.
|
|
27
|
+
*/
|
|
28
|
+
export function usePageVisibility(): Accessor<boolean> {
|
|
29
|
+
const slot = useContext(PageContext)
|
|
30
|
+
return slot === undefined ? ALWAYS : () => !slot.hidden()
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const onVisibility = (kind: "hide" | "show", callback: () => void): void => {
|
|
34
|
+
const slot = useContext(PageContext)
|
|
35
|
+
if (slot === undefined) return
|
|
36
|
+
slot[kind].add(callback)
|
|
37
|
+
onCleanup(() => slot[kind].delete(callback))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Run `callback` each time the `keepAlive` page this component renders in is hidden behind another
|
|
42
|
+
* page, before it leaves the document. The page keeps running while it is hidden: pause its timers,
|
|
43
|
+
* sockets and media here. Call it while the component sets up; it is removed with the component. A
|
|
44
|
+
* no-op outside a kept page.
|
|
45
|
+
*/
|
|
46
|
+
export function onPageHide(callback: () => void): void {
|
|
47
|
+
onVisibility("hide", callback)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Run `callback` each time the `keepAlive` page this component renders in is shown again. */
|
|
51
|
+
export function onPageShow(callback: () => void): void {
|
|
52
|
+
onVisibility("show", callback)
|
|
53
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { type HoleRoot, holeHost } from "@nifrajs/web/internal/hole-runtime"
|
|
2
|
+
import {
|
|
3
|
+
createComponent,
|
|
4
|
+
createEffect,
|
|
5
|
+
type JSX,
|
|
6
|
+
on,
|
|
7
|
+
onCleanup,
|
|
8
|
+
onMount,
|
|
9
|
+
sharedConfig,
|
|
10
|
+
} from "solid-js"
|
|
11
|
+
import { createStore, reconcile } from "solid-js/store"
|
|
12
|
+
import { Dynamic, hydrate } from "solid-js/web"
|
|
13
|
+
|
|
14
|
+
const isObject = (value: unknown): value is object => typeof value === "object" && value !== null
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A hole's host in the browser: once mounted, the PPR runtime's fill goes in, an interactive hole
|
|
18
|
+
* hydrates that server HTML as its own root from the frame's props, and each refill `fill` brings is
|
|
19
|
+
* applied in place. Object props reach the root as a store, so a refill updates it and keeps its
|
|
20
|
+
* state; any other value makes the root again.
|
|
21
|
+
*/
|
|
22
|
+
export function clientHost<Data>(
|
|
23
|
+
name: string,
|
|
24
|
+
render: (data: Data) => JSX.Element,
|
|
25
|
+
interactive: boolean,
|
|
26
|
+
attributes: Readonly<Record<string, string>>,
|
|
27
|
+
fill: () => unknown,
|
|
28
|
+
): JSX.Element {
|
|
29
|
+
let host: HTMLElement | undefined
|
|
30
|
+
const element = createComponent(Dynamic, {
|
|
31
|
+
component: "div",
|
|
32
|
+
ref: (el: HTMLElement) => {
|
|
33
|
+
host = el
|
|
34
|
+
},
|
|
35
|
+
...attributes,
|
|
36
|
+
})
|
|
37
|
+
onMount(() => {
|
|
38
|
+
const target = host
|
|
39
|
+
if (target === undefined) return
|
|
40
|
+
const root = (into: Element, data: unknown): HoleRoot => {
|
|
41
|
+
const [props, setProps] = createStore({ value: data })
|
|
42
|
+
let dispose: (() => void) | undefined
|
|
43
|
+
let left = false
|
|
44
|
+
const start = (): void => {
|
|
45
|
+
if (left) return
|
|
46
|
+
dispose = hydrate(
|
|
47
|
+
// biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: the server framed these props from this hole's own loader, through its output guard.
|
|
48
|
+
() => render(props.value as Data),
|
|
49
|
+
into,
|
|
50
|
+
{ renderId: `nifra-${name}-` },
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
// Solid keeps one hydration context: a fill met while the page hydrates waits for it to end.
|
|
54
|
+
if (sharedConfig.context) queueMicrotask(start)
|
|
55
|
+
else start()
|
|
56
|
+
return {
|
|
57
|
+
update: (next) => {
|
|
58
|
+
if (!isObject(next) || !isObject(props.value)) return false
|
|
59
|
+
setProps("value", reconcile(next))
|
|
60
|
+
return true
|
|
61
|
+
},
|
|
62
|
+
dispose: () => {
|
|
63
|
+
left = true
|
|
64
|
+
dispose?.()
|
|
65
|
+
},
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const driven = holeHost(name, target, interactive ? root : undefined)
|
|
69
|
+
createEffect(
|
|
70
|
+
on(fill, (next) => {
|
|
71
|
+
if (next !== undefined) driven.refill(next)
|
|
72
|
+
}),
|
|
73
|
+
)
|
|
74
|
+
onCleanup(() => driven.dispose())
|
|
75
|
+
})
|
|
76
|
+
return element
|
|
77
|
+
}
|
package/src/ppr.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { initialHtml } from "@nifrajs/web/internal/hole-runtime"
|
|
2
|
+
import { createComponent, createMemo, type JSX, untrack } from "solid-js"
|
|
3
|
+
import { Dynamic, isServer } from "solid-js/web"
|
|
4
|
+
import { Await } from "./await.ts"
|
|
5
|
+
import { clientHost } from "./ppr-client.ts"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The fields of a route's boundary declaration a hole renders from. `fallback` is a function: JSX
|
|
9
|
+
* evaluated at import is one DOM node, so every place that shows it would take it from the last.
|
|
10
|
+
*/
|
|
11
|
+
export interface HoleDefinition<Data> {
|
|
12
|
+
readonly name: string
|
|
13
|
+
readonly render: (data: Data) => JSX.Element
|
|
14
|
+
readonly fallback?: () => JSX.Element
|
|
15
|
+
readonly interactive?: boolean
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The slice of a boundary state a hole reads: `ppr` marks a hole the shared shell left open, and
|
|
19
|
+
* `fill` that host's refill when the page reloads. */
|
|
20
|
+
interface HoleState {
|
|
21
|
+
readonly status: "unresolved" | "pending" | "ready" | "error"
|
|
22
|
+
readonly data?: unknown
|
|
23
|
+
readonly ppr?: true
|
|
24
|
+
readonly fill?: unknown
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface HoleProps<Data> {
|
|
28
|
+
readonly of: HoleDefinition<Data>
|
|
29
|
+
readonly state: HoleState | undefined
|
|
30
|
+
/** Shown when the hole's loader failed. Default: nothing. */
|
|
31
|
+
readonly error?: JSX.Element
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** An open hole's host: claimed by its hydration key like any element, never given children in the
|
|
35
|
+
* browser. On the server it holds a blocking render's HTML. */
|
|
36
|
+
function openHost<Data>(hole: HoleDefinition<Data>, fill: () => unknown): JSX.Element {
|
|
37
|
+
const attributes = { "data-nifra-hole": hole.name, "aria-busy": "true" }
|
|
38
|
+
if (isServer) {
|
|
39
|
+
const initial = initialHtml(fill())
|
|
40
|
+
return createComponent(Dynamic, {
|
|
41
|
+
component: "div",
|
|
42
|
+
...attributes,
|
|
43
|
+
...(initial === "" ? {} : { innerHTML: initial }),
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
return clientHost(hole.name, hole.render, hole.interactive === true, attributes, fill)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Experimental: one `dynamic` or `shared` boundary of a partially prerendered page.
|
|
51
|
+
*
|
|
52
|
+
* In a shared shell the hole is an empty host Solid never fills: the PPR runtime puts the server's
|
|
53
|
+
* HTML into it, and an interactive hole then hydrates as its own root from the frame's props. When the
|
|
54
|
+
* page reloads, after an action or a live change, the same host takes the new fill in place: an
|
|
55
|
+
* interactive root gets the new props through its store and keeps its state. After a client
|
|
56
|
+
* navigation the hole renders like any streamed boundary.
|
|
57
|
+
*/
|
|
58
|
+
export function Hole<Data>(props: HoleProps<Data>): JSX.Element {
|
|
59
|
+
const open = createMemo(() => props.state?.ppr === true)
|
|
60
|
+
const fill = createMemo(() => (open() ? props.state?.fill : undefined))
|
|
61
|
+
const view = createMemo(() => {
|
|
62
|
+
// Only `open` is read for a host, so a refill keeps it.
|
|
63
|
+
if (open()) return untrack(() => openHost(props.of, fill))
|
|
64
|
+
const hole = props.of
|
|
65
|
+
const state = props.state
|
|
66
|
+
if (state === undefined) return untrack(() => hole.fallback?.())
|
|
67
|
+
if (state.status === "error") return props.error
|
|
68
|
+
// biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: a ready state carries this boundary's loader output, through its output guard.
|
|
69
|
+
if (state.status === "ready") return untrack(() => hole.render(state.data as Data))
|
|
70
|
+
// A streamed hole's data is its deferred loader output, whatever status the payload names.
|
|
71
|
+
if (state.data === undefined) return untrack(() => hole.fallback?.())
|
|
72
|
+
return untrack(() =>
|
|
73
|
+
createComponent(Await<Data>, {
|
|
74
|
+
// biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: a pending streamed state carries this boundary's deferred loader output.
|
|
75
|
+
resolve: state.data as Data,
|
|
76
|
+
get fallback() {
|
|
77
|
+
return hole.fallback?.()
|
|
78
|
+
},
|
|
79
|
+
errorFallback: () => props.error,
|
|
80
|
+
children: hole.render,
|
|
81
|
+
}),
|
|
82
|
+
)
|
|
83
|
+
})
|
|
84
|
+
// biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion lint/plugin/noChainedTypeAssertions: Solid inserts an accessor reactively, as its own Show returns one; its JSX.Element type leaves functions out.
|
|
85
|
+
return view as unknown as JSX.Element
|
|
86
|
+
}
|