@nifrajs/web-solid 0.1.0-alpha.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.
Files changed (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +18 -0
  3. package/dist/await.d.ts +29 -0
  4. package/dist/await.d.ts.map +1 -0
  5. package/dist/await.js +46 -0
  6. package/dist/await.js.map +1 -0
  7. package/dist/client.d.ts +18 -0
  8. package/dist/client.d.ts.map +1 -0
  9. package/dist/client.js +85 -0
  10. package/dist/client.js.map +1 -0
  11. package/dist/compose.d.ts +9 -0
  12. package/dist/compose.d.ts.map +1 -0
  13. package/dist/compose.js +17 -0
  14. package/dist/compose.js.map +1 -0
  15. package/dist/content.d.ts +21 -0
  16. package/dist/content.d.ts.map +1 -0
  17. package/dist/content.js +18 -0
  18. package/dist/content.js.map +1 -0
  19. package/dist/error.d.ts +8 -0
  20. package/dist/error.d.ts.map +1 -0
  21. package/dist/error.js +25 -0
  22. package/dist/error.js.map +1 -0
  23. package/dist/fetcher.d.ts +37 -0
  24. package/dist/fetcher.d.ts.map +1 -0
  25. package/dist/fetcher.js +48 -0
  26. package/dist/fetcher.js.map +1 -0
  27. package/dist/i18n.d.ts +21 -0
  28. package/dist/i18n.d.ts.map +1 -0
  29. package/dist/i18n.js +31 -0
  30. package/dist/i18n.js.map +1 -0
  31. package/dist/image.d.ts +23 -0
  32. package/dist/image.d.ts.map +1 -0
  33. package/dist/image.js +49 -0
  34. package/dist/image.js.map +1 -0
  35. package/dist/index.d.ts +12 -0
  36. package/dist/index.d.ts.map +1 -0
  37. package/dist/index.js +67 -0
  38. package/dist/index.js.map +1 -0
  39. package/dist/mdx-runtime.d.ts +4 -0
  40. package/dist/mdx-runtime.d.ts.map +1 -0
  41. package/dist/mdx-runtime.js +52 -0
  42. package/dist/mdx-runtime.js.map +1 -0
  43. package/dist/mdx.d.ts +5 -0
  44. package/dist/mdx.d.ts.map +1 -0
  45. package/dist/mdx.js +59 -0
  46. package/dist/mdx.js.map +1 -0
  47. package/dist/query.d.ts +28 -0
  48. package/dist/query.d.ts.map +1 -0
  49. package/dist/query.js +54 -0
  50. package/dist/query.js.map +1 -0
  51. package/package.json +112 -0
  52. package/src/await.ts +58 -0
  53. package/src/client.ts +89 -0
  54. package/src/compose.ts +18 -0
  55. package/src/content.ts +30 -0
  56. package/src/error.ts +26 -0
  57. package/src/fetcher.ts +63 -0
  58. package/src/i18n.ts +46 -0
  59. package/src/image.ts +68 -0
  60. package/src/index.ts +75 -0
  61. package/src/mdx-runtime.ts +54 -0
  62. package/src/mdx.ts +60 -0
  63. package/src/query.ts +63 -0
package/src/error.ts ADDED
@@ -0,0 +1,26 @@
1
+ /**
2
+ * `errorBoundary` — the Solid error-boundary chain element for nifra's `_error.tsx`. Its own module
3
+ * (imports only `solid-js`) so the client codegen can import it from `@nifrajs/web-solid/client` (which
4
+ * re-exports it). No JSX (`createComponent`), so the package builds with plain `tsc`.
5
+ */
6
+ import { createComponent, ErrorBoundary, type JSX } from "solid-js"
7
+
8
+ /**
9
+ * Build an error-boundary chain element bound to `fallback` (a route's `_error` component). nifra's
10
+ * client codegen inserts it before the page in the matched chain; a render error in the subtree renders
11
+ * `fallback` with `{ data: { name, message } }` (via Solid's `<ErrorBoundary>`) instead of crashing.
12
+ * DOM-transparent (renders its children directly — no wrapper element), so it never disturbs hydration.
13
+ */
14
+ export function errorBoundary(fallback: unknown): unknown {
15
+ const Fallback = fallback as (props: { data: { name: string; message: string } }) => JSX.Element
16
+ return (props: { children?: JSX.Element }): JSX.Element =>
17
+ createComponent(ErrorBoundary, {
18
+ fallback: (err: unknown) => {
19
+ const e = err instanceof Error ? err : new Error(String(err))
20
+ return createComponent(Fallback, { data: { name: e.name, message: e.message } })
21
+ },
22
+ get children() {
23
+ return props.children
24
+ },
25
+ })
26
+ }
package/src/fetcher.ts ADDED
@@ -0,0 +1,63 @@
1
+ import type { ClientRouter, Fetcher, FetcherState } from "@nifrajs/web"
2
+ /**
3
+ * `@nifrajs/web-solid/fetcher` — Solid bindings for concurrent fetchers. `createFetcher(key)` bridges an
4
+ * independent {@link Fetcher}'s `subscribe`/`snapshot` store into a Solid signal (the pattern the F16
5
+ * spike confirmed) and returns its reactive state accessor plus `load`/`submit`; `useFetchers()`
6
+ * bridges the whole live collection. Imports only `solid-js` (never `solid-js/web`), so route
7
+ * components can use it on the server *and* client without pulling server/build code into the wrong
8
+ * bundle. No JSX (the package builds with plain `tsc`).
9
+ *
10
+ * The router that owns the fetchers registers itself via `setMountedRouter` from `mountRouter`. On the
11
+ * server (no mount) the accessors return an idle state — fetchers are client-only — so SSR markup is
12
+ * unchanged and the first client render after `mountRouter` takes over cleanly.
13
+ */
14
+ import { type Accessor, createSignal, onCleanup } from "solid-js"
15
+
16
+ // The active client router (set by `mountRouter`). Module-scoped: one app per page, client-only.
17
+ let mountedRouter: ClientRouter | undefined
18
+
19
+ /** Register (or clear) the router that owns fetchers — called by `mountRouter`. Not for app use. */
20
+ export function setMountedRouter(router: ClientRouter | undefined): void {
21
+ mountedRouter = router
22
+ }
23
+
24
+ const IDLE: FetcherState = { pending: false, data: undefined }
25
+ const noopAsync = async (): Promise<void> => {}
26
+
27
+ /** A fetcher's reactive state accessor plus its imperative `load`/`submit`. */
28
+ export interface FetcherHandle {
29
+ /** Reactive accessor for the fetcher's {@link FetcherState} (`pending`/`data`/`actionData`/...). */
30
+ readonly state: Accessor<FetcherState>
31
+ /** Load a route path's loader data into this fetcher (concurrent; doesn't touch the active view). */
32
+ readonly load: (path: string) => Promise<void>
33
+ /** Submit an action into this fetcher (concurrent); honors `X-Nifra-Revalidate`. */
34
+ readonly submit: (action: string, body: NonNullable<RequestInit["body"]>) => Promise<void>
35
+ }
36
+
37
+ /**
38
+ * Bind the independent fetcher for `key` (created lazily, stable). Returns a reactive `state()`
39
+ * accessor + `load`/`submit`. Multiple `createFetcher` calls with different keys run concurrently
40
+ * without disturbing the active route or each other. Call inside a component (owns the subscription).
41
+ */
42
+ export function createFetcher(key: string): FetcherHandle {
43
+ const fetcher = mountedRouter?.fetcher(key)
44
+ const [state, setState] = createSignal<FetcherState>(fetcher ? fetcher.snapshot() : IDLE)
45
+ if (fetcher !== undefined) {
46
+ onCleanup(fetcher.subscribe(() => setState(fetcher.snapshot())))
47
+ return { state, load: fetcher.load, submit: fetcher.submit }
48
+ }
49
+ return { state, load: noopAsync, submit: noopAsync }
50
+ }
51
+
52
+ /**
53
+ * Bind the whole live fetcher collection — for a global busy view. Returns a reactive accessor; read
54
+ * each entry's `.snapshot()` for its state. Updates whenever any fetcher transitions or one is created.
55
+ */
56
+ export function useFetchers(): Accessor<readonly Fetcher[]> {
57
+ const router = mountedRouter
58
+ const [list, setList] = createSignal<readonly Fetcher[]>(router ? router.fetchers() : [])
59
+ if (router !== undefined) {
60
+ onCleanup(router.subscribeFetchers(() => setList(router.fetchers())))
61
+ }
62
+ return list
63
+ }
package/src/i18n.ts ADDED
@@ -0,0 +1,46 @@
1
+ /**
2
+ * `@nifrajs/web-solid/i18n` — Solid bindings for `@nifrajs/i18n`. `<I18nProvider locale messages>` builds a
3
+ * `Formatter` (a `createMemo`, so switching locale rebuilds it) and provides it; `useT()` reads it.
4
+ * Both `locale` + `messages` are serializable, so a loader returns them, the page renders with the
5
+ * negotiated catalog on the server, and the client rebuilds the same formatter on hydrate (no
6
+ * mismatch). Imports only `solid-js` + `@nifrajs/i18n`; no JSX (`createComponent`).
7
+ */
8
+ import { createFormatter, type Formatter, type Messages } from "@nifrajs/i18n"
9
+ import {
10
+ type Accessor,
11
+ createComponent,
12
+ createContext,
13
+ createMemo,
14
+ type JSX,
15
+ useContext,
16
+ } from "solid-js"
17
+
18
+ const I18nContext = createContext<Accessor<Formatter>>()
19
+
20
+ export interface I18nProviderProps {
21
+ readonly locale: string
22
+ readonly messages: Messages
23
+ readonly children?: JSX.Element
24
+ }
25
+
26
+ /** Provide a {@link Formatter} (built from `locale` + `messages`) to the subtree. Memoized on
27
+ * `locale`/`messages`, so switching locale rebuilds it. */
28
+ export function I18nProvider(props: I18nProviderProps): JSX.Element {
29
+ const formatter = createMemo(() => createFormatter(props.locale, props.messages))
30
+ return createComponent(I18nContext.Provider, {
31
+ value: formatter,
32
+ get children() {
33
+ return props.children
34
+ },
35
+ })
36
+ }
37
+
38
+ /** Read the current {@link Formatter} (`{ locale, t, n, d }`). Throws if no `<I18nProvider>` is above.
39
+ * nifra switches locale by re-navigating, which re-runs the consuming component with the new catalog. */
40
+ export function useT(): Formatter {
41
+ const formatter = useContext(I18nContext)
42
+ if (formatter === undefined) {
43
+ throw new Error("[nifra/web-solid] useT() must be used within an <I18nProvider>")
44
+ }
45
+ return formatter()
46
+ }
package/src/image.ts ADDED
@@ -0,0 +1,68 @@
1
+ /**
2
+ * `@nifrajs/web-solid/image` — a CLS-safe responsive Solid `<Image>`. A thin wrapper over `resolveImage`
3
+ * from `@nifrajs/image`: it computes the responsive `<img>` attributes (`src`/`srcset`/`sizes`/`width`/
4
+ * `height`/`loading`/`decoding`/`fetchpriority` via `toHtmlAttrs`) and renders an `<img>` through
5
+ * Solid's `<Dynamic>`. Extra DOM props (`class`, `style`, `id`, `data-*`, handlers) pass through
6
+ * reactively (`splitProps`/`mergeProps`). Resizing is delegated to the `loader`; nifra bundles no codec.
7
+ * No JSX (`createComponent`/`Dynamic`), so the package builds with plain `tsc`.
8
+ */
9
+ import { type ImageLoader, type ImageProps, resolveImage, toHtmlAttrs } from "@nifrajs/image"
10
+ import { createComponent, type JSX, mergeProps, splitProps } from "solid-js"
11
+ import { Dynamic } from "solid-js/web"
12
+
13
+ /** Attributes `resolveImage` computes — callers can't override them via DOM passthrough. */
14
+ type ComputedAttrs =
15
+ | "src"
16
+ | "srcset"
17
+ | "sizes"
18
+ | "width"
19
+ | "height"
20
+ | "alt"
21
+ | "loading"
22
+ | "decoding"
23
+ | "fetchpriority"
24
+
25
+ export interface ImageComponentProps
26
+ extends ImageProps,
27
+ Omit<JSX.ImgHTMLAttributes<HTMLImageElement>, ComputedAttrs | keyof ImageProps> {
28
+ /** CDN URL builder. Defaults to the identity loader (no transform — still CLS-safe + lazy). */
29
+ readonly loader?: ImageLoader
30
+ }
31
+
32
+ /**
33
+ * Render a responsive, CLS-safe `<img>`. `width`+`height` are required (reserve layout space);
34
+ * `priority` marks the LCP image (`eager` + `fetchpriority="high"`). Extra DOM props pass through.
35
+ */
36
+ export function Image(props: ImageComponentProps): JSX.Element {
37
+ // Separate the nifra-only props (not valid DOM attributes) from the DOM passthrough (`rest`), keeping
38
+ // both reactive. `rest` carries class/style/id/handlers; the computed attrs are layered over it.
39
+ const [, rest] = splitProps(props, [
40
+ "loader",
41
+ "widths",
42
+ "quality",
43
+ "priority",
44
+ "src",
45
+ "width",
46
+ "height",
47
+ "alt",
48
+ "sizes",
49
+ "loading",
50
+ ])
51
+ const attrs = (): ReturnType<typeof toHtmlAttrs> => {
52
+ // Build ImageProps via conditional spreads (exactOptionalPropertyTypes: an unset optional is
53
+ // absent, not `undefined`). resolveImage validates width/height > 0 (CLS contract).
54
+ const input: ImageProps = {
55
+ src: props.src,
56
+ width: props.width,
57
+ height: props.height,
58
+ alt: props.alt,
59
+ ...(props.sizes !== undefined ? { sizes: props.sizes } : {}),
60
+ ...(props.widths !== undefined ? { widths: props.widths } : {}),
61
+ ...(props.quality !== undefined ? { quality: props.quality } : {}),
62
+ ...(props.loading !== undefined ? { loading: props.loading } : {}),
63
+ ...(props.priority !== undefined ? { priority: props.priority } : {}),
64
+ }
65
+ return toHtmlAttrs(resolveImage(input, props.loader))
66
+ }
67
+ return createComponent(Dynamic, mergeProps(rest, attrs, { component: "img" }))
68
+ }
package/src/index.ts ADDED
@@ -0,0 +1,75 @@
1
+ /**
2
+ * @nifrajs/web-solid — the Solid render adapter for @nifrajs/web (server side) + the Solid Babel
3
+ * Bun-plugin. Streaming SSR via `renderToStream` + `generateHydrationScript`; the layout-chain
4
+ * fold is in `./compose`. Client hydration lives in `@nifrajs/web-solid/client` (Solid's browser
5
+ * build).
6
+ */
7
+ import { transformAsync } from "@babel/core"
8
+ // @ts-expect-error — no type declarations published
9
+ import presetTypeScript from "@babel/preset-typescript"
10
+ import type { RenderAdapter } from "@nifrajs/web"
11
+ // @ts-expect-error — no type declarations published
12
+ import presetSolid from "babel-preset-solid"
13
+ import type { BunPlugin } from "bun"
14
+ import {
15
+ generateHydrationScript,
16
+ renderToStream as solidRenderToStream,
17
+ renderToString as solidRenderToString,
18
+ } from "solid-js/web"
19
+ import { compose } from "./compose.ts"
20
+
21
+ const HYDRATION_HEAD = generateHydrationScript()
22
+
23
+ /** The Solid server render adapter — pass to @nifrajs/web's `renderPage`. */
24
+ export const solidAdapter: RenderAdapter = {
25
+ // Synchronous one-pass render for non-deferred pages (renderPage's buffered fast path). Solid's
26
+ // `renderToString` emits the same hydratable markup (same `data-hk` keys seeded by
27
+ // `generateHydrationScript()`) as `renderToStream`, but skips the TransformStream + Solid's
28
+ // streaming machinery — the heaviest of the five renderers on Bun. A page that defer()s/Suspends
29
+ // takes `renderToStream` below (progressive resolution needs it).
30
+ renderToString(chain, props) {
31
+ return solidRenderToString(compose(chain, props))
32
+ },
33
+ renderToStream(chain, props) {
34
+ // Solid's `renderToStream` streams `Uint8Array` chunks into a Web `WritableStream` via
35
+ // `pipeTo` (fire-and-forget — returns void); pipe into a TransformStream and hand back the
36
+ // readable side. Suspense boundaries stream as they resolve; `generateHydrationScript()` (in
37
+ // <head>) seeds client hydration. A render failure errors `ts.readable`, which `renderPage`
38
+ // surfaces on the response body.
39
+ const ts = new TransformStream<Uint8Array, Uint8Array>()
40
+ solidRenderToStream(compose(chain, props)).pipeTo(ts.writable)
41
+ return ts.readable
42
+ },
43
+ hydrationHead() {
44
+ return HYDRATION_HEAD
45
+ },
46
+ }
47
+
48
+ /**
49
+ * Bun build/runtime plugin that compiles Solid components with Babel — `generate: "ssr"`
50
+ * for the server, `"dom"` for the client, `hydratable` so SSR and hydrate align. Solid's
51
+ * reactive-JSX compiler ships only as a Babel plugin (no swc/native port); this runs at
52
+ * build time, on `.tsx` files only.
53
+ */
54
+ export function solidBunPlugin(generate: "dom" | "ssr"): BunPlugin {
55
+ return {
56
+ name: `nifra-solid-${generate}`,
57
+ setup(build) {
58
+ // Match `.tsx`, tolerating a `?query` suffix (dev servers append one to bust Bun's import
59
+ // cache); strip it before reading the file off disk.
60
+ build.onLoad({ filter: /\.tsx(\?|$)/ }, async (args) => {
61
+ const path = args.path.split("?")[0] ?? args.path
62
+ const source = await Bun.file(path).text()
63
+ const result = await transformAsync(source, {
64
+ filename: path,
65
+ // babel applies presets last→first: strip TS first, then Solid transforms JSX.
66
+ presets: [
67
+ [presetSolid, { generate, hydratable: true }],
68
+ [presetTypeScript, { onlyRemoveTypeImports: true }],
69
+ ],
70
+ })
71
+ return { contents: result?.code ?? "", loader: "js" }
72
+ })
73
+ },
74
+ }
75
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Solid MDX runtime — the `useMDXComponents` provider that `@nifrajs/web-solid/mdx`'s compiled MDX imports
3
+ * (via `@mdx-js`'s `providerImportSource`). MDX emits intrinsic elements (`h1`, `p`, …) as *string*
4
+ * component references, but Solid can't `createComponent("h1")` — its JSX is compile-time. So we map
5
+ * each Markdown-output tag to a Solid component that renders it via `<Dynamic component={tag}>`.
6
+ */
7
+ import { createComponent } from "solid-js"
8
+ import { Dynamic } from "solid-js/web"
9
+
10
+ // The HTML element set CommonMark + GFM produce. An explicit map (not a Proxy): `@mdx-js` *spreads* the
11
+ // provider's result into a plain object, so only own-enumerable keys survive.
12
+ const MARKDOWN_TAGS = [
13
+ "a",
14
+ "blockquote",
15
+ "br",
16
+ "code",
17
+ "del",
18
+ "em",
19
+ "h1",
20
+ "h2",
21
+ "h3",
22
+ "h4",
23
+ "h5",
24
+ "h6",
25
+ "hr",
26
+ "img",
27
+ "input",
28
+ "li",
29
+ "ol",
30
+ "p",
31
+ "pre",
32
+ "section",
33
+ "span",
34
+ "strong",
35
+ "sup",
36
+ "table",
37
+ "tbody",
38
+ "td",
39
+ "th",
40
+ "thead",
41
+ "tr",
42
+ "ul",
43
+ ] as const
44
+
45
+ const components: Record<string, (props: Record<string, unknown>) => unknown> = {}
46
+ for (const tag of MARKDOWN_TAGS) {
47
+ components[tag] = (props) => createComponent(Dynamic, Object.assign({ component: tag }, props))
48
+ }
49
+
50
+ /** Returns the intrinsic-element → Solid-component map MDX content uses. Merge in your own overrides by
51
+ * passing `components` to the MDX content component (they take precedence). */
52
+ export function useMDXComponents(): Record<string, (props: Record<string, unknown>) => unknown> {
53
+ return components
54
+ }
package/src/mdx.ts ADDED
@@ -0,0 +1,60 @@
1
+ /**
2
+ * `solidMdxBunPlugin` — compile `.mdx` routes/files to **Solid** components. MDX is JSX-oriented but
3
+ * Solid's JSX is compile-time, so this: (1) compiles MDX → JSX with `@mdx-js` (keeping JSX), pointing
4
+ * intrinsic elements at `@nifrajs/web-solid/mdx-runtime` (which renders them via Solid's `<Dynamic>`), then
5
+ * (2) runs `babel-preset-solid` — the same transform `solidBunPlugin` applies to `.tsx`. Pass it to
6
+ * `buildClient`/`buildServer`'s `plugins` (`"dom"` for the client, `"ssr"` for the server), like
7
+ * `solidBunPlugin`. `@mdx-js/mdx` is an optional peer (lazy-loaded at build time).
8
+ */
9
+ import { transformAsync } from "@babel/core"
10
+ // @ts-expect-error — no type declarations published
11
+ import presetTypeScript from "@babel/preset-typescript"
12
+ // @ts-expect-error — no type declarations published
13
+ import presetSolid from "babel-preset-solid"
14
+ import type { BunPlugin } from "bun"
15
+
16
+ // Non-literal so tsc/consumers don't need `@mdx-js/mdx`'s types to typecheck this file (optional peer).
17
+ const MDX_MODULE = "@mdx-js/mdx"
18
+
19
+ interface MdxCompiler {
20
+ compile(source: string, options?: Record<string, unknown>): Promise<{ toString(): string }>
21
+ }
22
+
23
+ /** Build a `Bun.build` plugin that loads `.mdx` files as Solid components. `generate`: `"ssr"` for the
24
+ * server build, `"dom"` for the client (matches `solidBunPlugin`). */
25
+ export function solidMdxBunPlugin(generate: "dom" | "ssr"): BunPlugin {
26
+ return {
27
+ name: `nifra-solid-mdx-${generate}`,
28
+ async setup(build) {
29
+ let compile: MdxCompiler["compile"]
30
+ try {
31
+ compile = ((await import(MDX_MODULE)) as MdxCompiler).compile
32
+ } catch {
33
+ throw new Error(
34
+ "@nifrajs/web-solid/mdx: compiling `.mdx` needs the `@mdx-js/mdx` package — install it (`bun add @mdx-js/mdx`).",
35
+ )
36
+ }
37
+ build.onLoad({ filter: /\.mdx(\?|$)/ }, async (args) => {
38
+ const path = args.path.split("?")[0] ?? args.path
39
+ // 1. MDX → JSX, with intrinsics provided by the Solid runtime (rendered via `<Dynamic>`).
40
+ const jsx = String(
41
+ await compile(await Bun.file(path).text(), {
42
+ jsx: true,
43
+ providerImportSource: "@nifrajs/web-solid/mdx-runtime",
44
+ }),
45
+ )
46
+ // 2. babel-preset-solid (TS stripped first), exactly like `solidBunPlugin`.
47
+ const result = await transformAsync(jsx, {
48
+ filename: `${path}.tsx`,
49
+ presets: [
50
+ [presetSolid, { generate, hydratable: true }],
51
+ [presetTypeScript, { onlyRemoveTypeImports: true }],
52
+ ],
53
+ babelrc: false,
54
+ configFile: false,
55
+ })
56
+ return { contents: result?.code ?? "", loader: "js" }
57
+ })
58
+ },
59
+ }
60
+ }
package/src/query.ts ADDED
@@ -0,0 +1,63 @@
1
+ import { createQueryClient, type QueryClient, type QueryState } from "@nifrajs/web"
2
+ /**
3
+ * `@nifrajs/web-solid/query` — Solid bindings for the keyed query-cache. `createQuery(key, fn)` bridges a
4
+ * query's `subscribe`/`snapshot` store into a Solid signal and fetches on mount; `useQueryClient`
5
+ * exposes `invalidateQueries` for after a mutation. Imports only `solid-js` + `@nifrajs/web` (never
6
+ * `solid-js/web`), so route components use it on the server *and* client. No JSX.
7
+ *
8
+ * The query client is created lazily **client-side only** (`typeof window`) — on the server the
9
+ * accessor returns the idle/pending state (queries are client-first; loaders are the SSR data source).
10
+ * The `key` is captured once (Solid setup runs once); a reactive key is a documented future enhancement.
11
+ */
12
+ import { type Accessor, createSignal, onCleanup, onMount } from "solid-js"
13
+
14
+ let client: QueryClient | undefined
15
+ function getClient(): QueryClient | undefined {
16
+ if (typeof window === "undefined") return undefined // SSR → no client (queries render idle)
17
+ if (client === undefined) client = createQueryClient({ now: () => Date.now() })
18
+ return client
19
+ }
20
+
21
+ /** Access the query client to imperatively `invalidateQueries(keyOrPrefix)` (e.g. after a mutation). */
22
+ export function useQueryClient(): Pick<QueryClient, "invalidateQueries"> {
23
+ return getClient() ?? { invalidateQueries: () => {} }
24
+ }
25
+
26
+ const IDLE: QueryState<never> = Object.freeze({
27
+ status: "pending",
28
+ data: undefined,
29
+ error: undefined,
30
+ isFetching: false,
31
+ updatedAt: Number.NEGATIVE_INFINITY,
32
+ })
33
+
34
+ /** A query's reactive state accessor plus `refetch`. */
35
+ export interface CreateQueryResult<T> {
36
+ /** Reactive accessor for the {@link QueryState} (`status`/`data`/`error`/`isFetching`/`updatedAt`). */
37
+ readonly state: Accessor<QueryState<T>>
38
+ /** Force a refetch (ignores `staleTime`). */
39
+ readonly refetch: () => Promise<T>
40
+ }
41
+
42
+ /**
43
+ * Bind the keyed query for `key`, fetched via `fn`. Returns a reactive `state()` accessor + `refetch`.
44
+ * Concurrent `createQuery`s with the same key share one cache entry + one in-flight fetch (dedup).
45
+ * Fetches on mount; SSR-idle. Call inside a component (owns the subscription).
46
+ */
47
+ export function createQuery<T>(key: unknown, fn: () => Promise<T>): CreateQueryResult<T> {
48
+ const handle = getClient()?.query<T>(key, fn)
49
+ const [state, setState] = createSignal<QueryState<T>>(handle ? handle.snapshot() : IDLE)
50
+ if (handle !== undefined) {
51
+ onCleanup(handle.subscribe(() => setState(() => handle.snapshot())))
52
+ onMount(() => {
53
+ handle.fetch().catch(() => {})
54
+ })
55
+ return { state, refetch: handle.refetch }
56
+ }
57
+ return {
58
+ state,
59
+ refetch: async () => {
60
+ throw new Error("[nifra/web-solid] createQuery.refetch called with no query client (server?)")
61
+ },
62
+ }
63
+ }