@nifrajs/web-solid 3.3.0 → 4.0.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/src/plugin.ts ADDED
@@ -0,0 +1,93 @@
1
+ /**
2
+ * `@nifrajs/web-solid/plugin` - the Solid Babel Bun plugin, in its OWN module so the adapter root (which
3
+ * every Solid SSR server and edge worker bundles) never links build-time code. Mirrors
4
+ * `@nifrajs/web-vue/plugin`: pass `"dom"` for the client bundle and `"ssr"` for the server.
5
+ */
6
+ import { transformAsync } from "@babel/core"
7
+ // @ts-expect-error - no type declarations published
8
+ import presetTypeScript from "@babel/preset-typescript"
9
+ import { devServerCompile, normalizeFilePath, rewriteSsrImports } from "@nifrajs/web/plugins/kit"
10
+ // @ts-expect-error - no type declarations published
11
+ import presetSolid from "babel-preset-solid"
12
+ import type { BunPlugin } from "bun"
13
+ import solidRefreshBunHot, { SOLID_HOT_MODULE } from "./refresh-babel.ts"
14
+
15
+ /**
16
+ * Bun build/runtime plugin that compiles Solid components with Babel - `generate: "ssr"`
17
+ * for the server, `"dom"` for the client, `hydratable` so SSR and hydrate align. Solid's
18
+ * reactive-JSX compiler ships only as a Babel plugin (no swc/native port); this runs at
19
+ * build time, on `.tsx` files only.
20
+ *
21
+ * Under a dev server the client half additionally runs `solid-refresh`, which is what makes an edit
22
+ * patch the running component tree instead of reloading the page - see {@link setupSolidRefresh}.
23
+ */
24
+ export function solidBunPlugin(generate: "dom" | "ssr"): BunPlugin {
25
+ return {
26
+ name: `nifra-solid-${generate}`,
27
+ setup(build) {
28
+ const refresh =
29
+ generate === "dom" && devServerCompile() ? setupSolidRefresh(build) : undefined
30
+ // Match `.tsx`, tolerating a `?query` suffix (dev servers append one to bust Bun's import
31
+ // cache); strip it before reading the file off disk.
32
+ build.onLoad({ filter: /\.tsx(\?|$)/ }, async (args) => {
33
+ const path = normalizeFilePath(args.path)
34
+ const source = await Bun.file(path).text()
35
+ const result = await transformAsync(source, {
36
+ filename: path,
37
+ // babel applies presets last→first: strip TS first, then Solid transforms JSX.
38
+ presets: [
39
+ [presetSolid, { generate, hydratable: true }],
40
+ [presetTypeScript, { onlyRemoveTypeImports: true }],
41
+ ],
42
+ ...(refresh !== undefined
43
+ ? { plugins: [[await refresh.babel(), REFRESH_OPTIONS], solidRefreshBunHot] }
44
+ : {}),
45
+ })
46
+ return { contents: rewriteSsrImports(result?.code ?? "", path, generate), loader: "js" }
47
+ })
48
+ },
49
+ }
50
+ }
51
+
52
+ /**
53
+ * `"esm"` is the closest of `solid-refresh`'s bundler modes to what Bun implements: one
54
+ * `accept(mod => …)` per module, falling back to `invalidate()` when a component's signature changed too
55
+ * much to patch in place. `"vite"` differs only by also calling a bare `accept()` first, which would make
56
+ * every module self-accepting twice over. The call it emits is still Vite-shaped, and
57
+ * `solidRefreshBunHot` translates it.
58
+ *
59
+ * `jsx: false` turns OFF the pass that lifts a component's returned JSX into a second, nested component.
60
+ * That extra component is an extra hydration level on the client and none on the server, so with it on,
61
+ * every hydratable page dies on `Unable to find DOM nodes for hydration key` before a single edit. It buys
62
+ * finer-grained patching of markup-only edits; correct hydration is worth more.
63
+ */
64
+ const REFRESH_OPTIONS = { bundler: "esm", jsx: false } as const
65
+
66
+ /**
67
+ * Wire `solid-refresh` into a dev-server client compile.
68
+ *
69
+ * Solid components are compiled reactive closures, not functions re-run on render, so a new module
70
+ * version cannot simply replace the old one - `solid-refresh` wraps every component in a registry and, on
71
+ * an update, patches the live instances, keeping signal state. Without it the update finds no accepting
72
+ * module, bubbles up to the generated entry, and Bun reloads the whole page.
73
+ *
74
+ * The Babel half loads lazily: it is dev-only, and every production build loads this plugin too.
75
+ *
76
+ * Both runtime specifiers need pinning, because the transform emits them INTO the app's own files, where
77
+ * a bare specifier resolves against the app. `solid-refresh` is only a transitive dependency there and
78
+ * need not be hoisted; `nifra:solid-hot` is this package's own bridge module and has no app-side name at
79
+ * all. Resolving both here is what keeps the emitted imports working without an app depending on either.
80
+ */
81
+ function setupSolidRefresh(build: Parameters<BunPlugin["setup"]>[0]): {
82
+ babel: () => Promise<unknown>
83
+ } {
84
+ const runtime = Bun.resolveSync("solid-refresh", import.meta.dir)
85
+ build.onResolve({ filter: /^solid-refresh$/ }, () => ({ path: runtime }))
86
+ // `.ts` from source (Bun takes the package's `bun` export condition), `.js` from the published build.
87
+ const bridge = `${import.meta.dir}/refresh-hot${import.meta.path.endsWith(".ts") ? ".ts" : ".js"}`
88
+ build.onResolve({ filter: new RegExp(`^${SOLID_HOT_MODULE}$`) }, () => ({ path: bridge }))
89
+ let babel: Promise<unknown> | undefined
90
+ return {
91
+ babel: () => (babel ??= import("solid-refresh/babel").then((m) => m.default)),
92
+ }
93
+ }
@@ -0,0 +1,55 @@
1
+ import type { MountRouterOptions, RenderProps, RouterState } from "@nifrajs/web"
2
+ // `/client`, not the root: the root's graph carries the server, and Vite's dev server evaluates it
3
+ // instead of tree-shaking it - which broke hydration before the browser ran a line of app code.
4
+ import { searchOfChain } from "@nifrajs/web/client"
5
+
6
+ /**
7
+ * The client half of the server's `RenderProps`, as getters so a settle re-renders only what reads the
8
+ * changed field. Cast: a getter always exists where `exactOptionalPropertyTypes` expects absence.
9
+ */
10
+ export function routeProps(
11
+ snapshot: () => RouterState,
12
+ searchSchemas: MountRouterOptions["searchSchemas"],
13
+ matchChains?: MountRouterOptions["matchChains"],
14
+ ): RenderProps {
15
+ return {
16
+ get data() {
17
+ return snapshot().data
18
+ },
19
+ // Each layout's own loader data. Without it a layout renders `data: null` on the client while the
20
+ // server rendered it with its data.
21
+ get layoutData() {
22
+ return snapshot().layoutData
23
+ },
24
+ get actionData() {
25
+ return snapshot().actionData
26
+ },
27
+ get pending() {
28
+ return snapshot().pending
29
+ },
30
+ // The same `params`/`path` the server render received, so `useMatches` reports the same chain.
31
+ get params() {
32
+ return snapshot().params
33
+ },
34
+ get path() {
35
+ return snapshot().path
36
+ },
37
+ get search() {
38
+ // The SAME searchOfChain the server ran, over this snapshot's URL - a same-route search change
39
+ // updates in place (fine-grained), matching the SSR value on hydration.
40
+ const s = snapshot()
41
+ const idx = s.path.indexOf("?")
42
+ return searchOfChain(searchSchemas?.[s.routeId] ?? [], idx === -1 ? "" : s.path.slice(idx))
43
+ },
44
+ get submission() {
45
+ return snapshot().submission
46
+ },
47
+ get boundaries() {
48
+ return snapshot().boundaries
49
+ },
50
+ // The chain `useMatches` reports - the server passed the same one to the SSR render.
51
+ get matchChain() {
52
+ return matchChains?.[snapshot().routeId]
53
+ },
54
+ } as RenderProps
55
+ }
package/src/router.ts CHANGED
@@ -9,6 +9,8 @@ import type {
9
9
  NavigateFunction,
10
10
  NavigateOptions,
11
11
  NavigateTargetInput,
12
+ RenderProps,
13
+ UIMatch,
12
14
  } from "@nifrajs/web"
13
15
  // `/client`, not the root - these are DOM values, and the root's graph carries the
14
16
  // server, which Vite's dev server evaluates rather than tree-shakes.
@@ -18,6 +20,8 @@ import {
18
20
  registerBlocker,
19
21
  resolveNavigate,
20
22
  } from "@nifrajs/web/client"
23
+ // Its own subpath, so an app that never calls `useMatches` never bundles it.
24
+ import { chainMatches } from "@nifrajs/web/internal/matches-runtime"
21
25
  /**
22
26
  * `@nifrajs/web-solid/router` - Solid routing bindings over the agnostic `@nifrajs/web` history layer:
23
27
  * `useNavigate` (programmatic navigation), `useBlocker` (the unsaved-changes guard), and `useSearch`
@@ -29,12 +33,13 @@ import {
29
33
  type Accessor,
30
34
  type Context,
31
35
  createContext,
36
+ createMemo,
32
37
  createSignal,
33
38
  onCleanup,
34
39
  useContext,
35
40
  } from "solid-js"
36
41
 
37
- export type { Blocker, BlockerFunction, BlockerState, NavigateFunction }
42
+ export type { Blocker, BlockerFunction, BlockerState, NavigateFunction, UIMatch }
38
43
 
39
44
  // Frozen empty search + a stable accessor for a `useSearch` used outside a nifra route tree.
40
45
  const EMPTY_SEARCH: Readonly<Record<string, unknown>> = Object.freeze({})
@@ -55,6 +60,34 @@ export const SearchContext =
55
60
  searchContextSlot[SEARCH_CONTEXT_SLOT] ?? createContext<Accessor<Record<string, unknown>>>()
56
61
  searchContextSlot[SEARCH_CONTEXT_SLOT] = SearchContext
57
62
 
63
+ /** The props `compose` rendered the chain with - getters over the router snapshot on the client - which
64
+ * {@link useMatches} reads. A `globalThis` singleton for the same reason as {@link SearchContext}. */
65
+ const PROPS_CONTEXT_SLOT = Symbol.for("nifra.web-solid.props-context")
66
+ const propsContextSlot = globalThis as { [PROPS_CONTEXT_SLOT]?: Context<RenderProps | undefined> }
67
+ export const RenderPropsContext =
68
+ propsContextSlot[PROPS_CONTEXT_SLOT] ?? createContext<RenderProps>()
69
+ propsContextSlot[PROPS_CONTEXT_SLOT] = RenderPropsContext
70
+
71
+ const NO_MATCHES: readonly UIMatch[] = Object.freeze([])
72
+
73
+ /**
74
+ * The rendered chain - each layout, then the page - with the URL prefix, params and loader data each
75
+ * one owns, plus its `handle` export, as a reactive accessor. The same list on the server render and
76
+ * the client mount, so a layout can render breadcrumbs from its children's `handle`s without a
77
+ * hydration mismatch; it updates in place when a same-route navigation settles.
78
+ *
79
+ * ```tsx
80
+ * export const handle = { crumb: "Settings" }
81
+ * const matches = useMatches()
82
+ * const crumbs = () => matches().flatMap((m) => (m.handle as { crumb?: string } | undefined)?.crumb ?? [])
83
+ * ```
84
+ */
85
+ export function useMatches(): Accessor<readonly UIMatch[]> {
86
+ const props = useContext(RenderPropsContext)
87
+ if (props === undefined) return () => NO_MATCHES
88
+ return createMemo(() => chainMatches(props))
89
+ }
90
+
58
91
  /**
59
92
  * The route's typed, validated search params as a reactive accessor - the SAME value the loader received
60
93
  * as `ctx.search`. SSR-correct: `compose` provides it from the URL server-side and from the identical