@nifrajs/web-solid 5.5.0 → 5.6.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.
Files changed (59) hide show
  1. package/CHANGELOG.md +107 -0
  2. package/dist/client.js +2 -2
  3. package/dist/client.js.map +1 -1
  4. package/dist/content.d.ts +1 -1
  5. package/dist/content.js +1 -1
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +40 -2
  8. package/dist/index.js.map +1 -1
  9. package/dist/keep-alive-view.d.ts +35 -0
  10. package/dist/keep-alive-view.d.ts.map +1 -0
  11. package/dist/keep-alive-view.js +135 -0
  12. package/dist/keep-alive-view.js.map +1 -0
  13. package/dist/keep-alive.d.ts +25 -0
  14. package/dist/keep-alive.d.ts.map +1 -0
  15. package/dist/keep-alive.js +51 -0
  16. package/dist/keep-alive.js.map +1 -0
  17. package/dist/mdx.d.ts.map +1 -1
  18. package/dist/mdx.js +5 -16
  19. package/dist/mdx.js.map +1 -1
  20. package/dist/page-visibility.d.ts +28 -0
  21. package/dist/page-visibility.d.ts.map +1 -0
  22. package/dist/page-visibility.js +42 -0
  23. package/dist/page-visibility.js.map +1 -0
  24. package/dist/ppr-client.d.ts +9 -0
  25. package/dist/ppr-client.d.ts.map +1 -0
  26. package/dist/ppr-client.js +63 -0
  27. package/dist/ppr-client.js.map +1 -0
  28. package/dist/ppr.d.ts +37 -0
  29. package/dist/ppr.d.ts.map +1 -0
  30. package/dist/ppr.js +61 -0
  31. package/dist/ppr.js.map +1 -0
  32. package/dist/query.d.ts +4 -3
  33. package/dist/query.d.ts.map +1 -1
  34. package/dist/query.js +7 -8
  35. package/dist/query.js.map +1 -1
  36. package/dist/route-props.d.ts +5 -2
  37. package/dist/route-props.d.ts.map +1 -1
  38. package/dist/route-props.js +16 -7
  39. package/dist/route-props.js.map +1 -1
  40. package/dist/router.d.ts +3 -2
  41. package/dist/router.d.ts.map +1 -1
  42. package/dist/router.js +7 -3
  43. package/dist/router.js.map +1 -1
  44. package/dist/server-components/Outlet.jsx +1 -1
  45. package/dist/server-components/Outlet.jsx.map +1 -1
  46. package/package.json +16 -5
  47. package/src/client.ts +2 -2
  48. package/src/content.ts +1 -1
  49. package/src/index.ts +48 -4
  50. package/src/keep-alive-view.ts +202 -0
  51. package/src/keep-alive.ts +76 -0
  52. package/src/mdx.ts +5 -9
  53. package/src/page-visibility.ts +53 -0
  54. package/src/ppr-client.ts +77 -0
  55. package/src/ppr.ts +86 -0
  56. package/src/query.ts +13 -8
  57. package/src/route-props.ts +16 -6
  58. package/src/router.ts +8 -3
  59. package/src/server-components/Outlet.tsx +1 -1
package/src/index.ts CHANGED
@@ -4,14 +4,15 @@
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
 
@@ -52,6 +53,27 @@ function redactingErrors<T>(app: () => T): () => T {
52
53
  }
53
54
  }
54
55
 
56
+ const noRef = (): undefined => undefined
57
+
58
+ /** The chain as the browser's keep-alive view renders it, in an app with a `keepAlive` route. */
59
+ function documentView(chain: readonly unknown[], props: RenderProps, slots: readonly number[]) {
60
+ const state: RouterState = { routeId: "", params: {}, path: "", data: props.data, pending: false }
61
+ const tree = documentTree(chain, state, slots)
62
+ return (): JSX.Element =>
63
+ KeepAliveView({
64
+ tree: () => tree,
65
+ shown: props,
66
+ mount: { propsOf: () => props, chainOf: () => chain, entry: noRef, page: noRef },
67
+ })
68
+ }
69
+
70
+ const appOf = (chain: readonly unknown[], props: RenderProps, options?: RenderStreamOptions) =>
71
+ redactingErrors(
72
+ options?.slots === undefined
73
+ ? compose(chain, props)
74
+ : documentView(chain, props, options.slots),
75
+ )
76
+
55
77
  /** The Solid server render adapter - pass to @nifrajs/web's `renderPage`. */
56
78
  export const solidAdapter: RenderAdapter = {
57
79
  // Synchronous one-pass render for non-deferred pages (renderPage's buffered fast path). Solid's
@@ -60,11 +82,33 @@ export const solidAdapter: RenderAdapter = {
60
82
  // streaming machinery - the heaviest of the five renderers on Bun. A page that defer()s/Suspends
61
83
  // takes `renderToStream` below (progressive resolution needs it).
62
84
  renderToString(chain, props, options) {
63
- const app = redactingErrors(compose(chain, props))
85
+ const app = appOf(chain, props, options)
64
86
  return options?.nonce === undefined
65
87
  ? solidRenderToString(app)
66
88
  : solidRenderToString(app, { nonce: options.nonce })
67
89
  },
90
+ // A partially prerendered hole's HTML: its own root under `renderId`, so its hydration keys and
91
+ // `createUniqueId`s never collide with the page's, and its JSX runs inside the render that keys it.
92
+ // Without a prefix (a fallback, never hydrated) it carries no hydration keys at all.
93
+ renderFragment(ui, options) {
94
+ // biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: a hole's render returns Solid output; the seam erases it to unknown.
95
+ const view = () => ui() as JSX.Element
96
+ // `renderToString` leaves its context in Solid's shared config; a page mid-render keeps its own.
97
+ const page = sharedConfig.context
98
+ try {
99
+ return options.idPrefix === undefined
100
+ ? solidRenderToString(() => {
101
+ // On the root context, so nodes resolved after `view` returns are unkeyed too.
102
+ if (sharedConfig.context !== undefined)
103
+ Reflect.set(sharedConfig.context, "noHydrate", true)
104
+ return view()
105
+ })
106
+ : solidRenderToString(view, { renderId: options.idPrefix })
107
+ } finally {
108
+ // Unset included: `context` is an optional property.
109
+ Reflect.set(sharedConfig, "context", page)
110
+ }
111
+ },
68
112
  renderToStream(chain, props, options) {
69
113
  // Solid's `renderToStream` streams `Uint8Array` chunks into a Web `WritableStream` via
70
114
  // `pipeTo` (fire-and-forget - returns void); pipe into a TransformStream and hand back the
@@ -72,7 +116,7 @@ export const solidAdapter: RenderAdapter = {
72
116
  // <head>) seeds client hydration. A render failure errors `ts.readable`, which `renderPage`
73
117
  // surfaces on the response body. The nonce reaches the resource and boundary scripts Solid streams.
74
118
  const ts = new TransformStream<Uint8Array, Uint8Array>()
75
- const app = redactingErrors(compose(chain, props))
119
+ const app = appOf(chain, props, options)
76
120
  const stream =
77
121
  options?.nonce === undefined
78
122
  ? 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
- let compile: MdxCompiler["compile"]
31
- try {
32
- compile = ((await import(MDX_MODULE)) as MdxCompiler).compile
33
- } catch {
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
+ }
package/src/query.ts CHANGED
@@ -1,8 +1,10 @@
1
- import type { QueryClient, QueryState } from "@nifrajs/web"
1
+ import type { QueryClient, QueryOptions, QueryState } from "@nifrajs/web"
2
2
  import {
3
3
  createNoClientRefetch,
4
+ fetchOnMount,
4
5
  getQueryClientSingleton,
5
6
  IDLE_QUERY_STATE,
7
+ noop,
6
8
  } from "@nifrajs/web/internal/query-runtime"
7
9
  /**
8
10
  * `@nifrajs/web-solid/query` - Solid bindings for the keyed query-cache. `createQuery(key, fn)` bridges a
@@ -18,7 +20,7 @@ import { type Accessor, createSignal, onCleanup, onMount } from "solid-js"
18
20
 
19
21
  /** Access the query client to imperatively `invalidateQueries(keyOrPrefix)` (e.g. after a mutation). */
20
22
  export function useQueryClient(): Pick<QueryClient, "invalidateQueries"> {
21
- return getQueryClientSingleton() ?? { invalidateQueries: () => {} }
23
+ return getQueryClientSingleton() ?? { invalidateQueries: noop }
22
24
  }
23
25
 
24
26
  /** A query's reactive state accessor plus `refetch`. */
@@ -32,18 +34,21 @@ export interface CreateQueryResult<T> {
32
34
  /**
33
35
  * Bind the keyed query for `key`, fetched via `fn`. Returns a reactive `state()` accessor + `refetch`.
34
36
  * Concurrent `createQuery`s with the same key share one cache entry + one in-flight fetch (dedup).
35
- * Fetches on mount; SSR-idle. Call inside a component (owns the subscription).
37
+ * Fetches on mount; SSR-idle. Call inside a component (owns the subscription). `options.live` names
38
+ * the live keys that refetch it.
36
39
  */
37
- export function createQuery<T>(key: unknown, fn: () => Promise<T>): CreateQueryResult<T> {
38
- const handle = getQueryClientSingleton()?.query<T>(key, fn)
40
+ export function createQuery<T>(
41
+ key: unknown,
42
+ fn: () => Promise<T>,
43
+ options?: QueryOptions,
44
+ ): CreateQueryResult<T> {
45
+ const handle = getQueryClientSingleton()?.query<T>(key, fn, options)
39
46
  const [state, setState] = createSignal<QueryState<T>>(
40
47
  handle ? handle.snapshot() : IDLE_QUERY_STATE,
41
48
  )
42
49
  if (handle !== undefined) {
43
50
  onCleanup(handle.subscribe(() => setState(() => handle.snapshot())))
44
- onMount(() => {
45
- handle.fetch().catch(() => {})
46
- })
51
+ onMount(fetchOnMount(handle))
47
52
  return { state, refetch: handle.refetch }
48
53
  }
49
54
  return {