@nifrajs/web-preact 2.2.0 → 2.3.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/README.md +3 -3
- package/dist/await.js +1 -1
- package/dist/client.d.ts +2 -2
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +11 -5
- package/dist/client.js.map +1 -1
- package/dist/compose.d.ts +4 -2
- package/dist/compose.d.ts.map +1 -1
- package/dist/compose.js +15 -6
- package/dist/compose.js.map +1 -1
- package/dist/content.d.ts +2 -2
- package/dist/content.js +2 -2
- package/dist/error.d.ts +1 -1
- package/dist/error.js +3 -3
- package/dist/fetcher.d.ts +2 -2
- package/dist/fetcher.js +5 -5
- package/dist/fn.d.ts +26 -0
- package/dist/fn.d.ts.map +1 -0
- package/dist/fn.js +28 -0
- package/dist/fn.js.map +1 -0
- package/dist/i18n.d.ts +2 -2
- package/dist/i18n.js +3 -3
- package/dist/image.d.ts +4 -4
- package/dist/image.js +2 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.js +4 -4
- package/dist/preact-render.d.ts +3 -3
- package/dist/preact-render.js +3 -3
- package/dist/query.d.ts +1 -1
- package/dist/query.js +2 -2
- package/dist/read-deferred.d.ts +2 -2
- package/dist/read-deferred.js +1 -1
- package/dist/router.d.ts +34 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/router.js +64 -0
- package/dist/router.js.map +1 -0
- package/package.json +16 -5
- package/src/await.ts +1 -1
- package/src/client.ts +11 -6
- package/src/compose.ts +21 -6
- package/src/content.ts +2 -2
- package/src/error.ts +3 -3
- package/src/fetcher.ts +5 -5
- package/src/fn.ts +40 -0
- package/src/i18n.ts +3 -3
- package/src/image.ts +4 -4
- package/src/index.ts +4 -4
- package/src/preact-render.ts +4 -4
- package/src/query.ts +3 -3
- package/src/read-deferred.ts +3 -3
- package/src/router.ts +88 -0
package/dist/router.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { getBrowserNavigate, IDLE_BLOCKER, registerBlocker, resolveNavigate, } from "@nifrajs/web";
|
|
2
|
+
/**
|
|
3
|
+
* `@nifrajs/web-preact/router` - Preact routing bindings over the agnostic `@nifrajs/web` history layer:
|
|
4
|
+
* `useNavigate` (programmatic navigation), `useBlocker` (the unsaved-changes guard), and `useSearch`
|
|
5
|
+
* (the route's typed, validated search). Navigation goes through `@nifrajs/web`'s DOM-free bridges
|
|
6
|
+
* (`getBrowserNavigate` / `registerBlocker`, populated by `installHistory`); `useSearch` reads the value
|
|
7
|
+
* `compose` provides on SSR + client mount alike. Client-safe (no `preact-render-to-string`). No JSX.
|
|
8
|
+
*/
|
|
9
|
+
import { createContext } from "preact";
|
|
10
|
+
import { useCallback, useContext, useEffect, useRef, useState } from "preact/compat";
|
|
11
|
+
// Frozen empty search so the default context value has a stable reference.
|
|
12
|
+
const EMPTY_SEARCH = Object.freeze({});
|
|
13
|
+
/** The current route's validated search, provided by `compose` on SSR + client mount alike (derived from
|
|
14
|
+
* the URL via the shared `searchOfChain`). `{}` outside a nifra route tree. Read via {@link useSearch}. */
|
|
15
|
+
export const SearchContext = createContext(EMPTY_SEARCH);
|
|
16
|
+
/**
|
|
17
|
+
* The route's typed, validated search params - the SAME value the loader received as `ctx.search`.
|
|
18
|
+
* SSR-correct: `compose` provides it from the URL server-side and from the identical derivation on the
|
|
19
|
+
* client mount, so a value rendered from it doesn't flash on hydration. Hostile input already failed
|
|
20
|
+
* closed to the schema's defaults at match time. Pass the route's `searchSchema` as the type argument to
|
|
21
|
+
* get its output type; bare, it's the raw parsed query (`Record<string, unknown>`).
|
|
22
|
+
*
|
|
23
|
+
* ```tsx
|
|
24
|
+
* export const searchSchema = v.object({ page: v.optional(v.fallback(v.number(), 1), 1) })
|
|
25
|
+
* const { page } = useSearch<typeof searchSchema>() // page: number
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
export function useSearch() {
|
|
29
|
+
return useContext(SearchContext);
|
|
30
|
+
}
|
|
31
|
+
/** Get the {@link NavigateFunction} (a string path, a history delta, or a typed `{ to, search }` object).
|
|
32
|
+
* Stable across renders; resolves the browser navigate at call time, so it works as soon as
|
|
33
|
+
* `installHistory` has run and no-ops before then / on the server. */
|
|
34
|
+
export function useNavigate() {
|
|
35
|
+
return useCallback((to, options) => {
|
|
36
|
+
const navigate = getBrowserNavigate();
|
|
37
|
+
if (navigate === undefined)
|
|
38
|
+
return;
|
|
39
|
+
const resolved = resolveNavigate(to, options);
|
|
40
|
+
navigate(resolved.to, resolved.options);
|
|
41
|
+
}, []);
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Guard navigation away from a page with unsaved work, confirming with your OWN async UI. Mirrors
|
|
45
|
+
* react-router's `useBlocker`: pass a boolean (`useBlocker(isDirty)`) or a predicate
|
|
46
|
+
* `({ currentLocation, nextLocation }) => boolean`, and get back a {@link Blocker}. When a navigation
|
|
47
|
+
* (an anchor click, `useNavigate`, or a browser back/forward) is intercepted, `blocker.state` becomes
|
|
48
|
+
* `"blocked"` and `proceed`/`reset` go live - render a dialog and call `proceed()` to continue or
|
|
49
|
+
* `reset()` to stay put. It also arms the browser's native "Leave site?" prompt on tab close / reload.
|
|
50
|
+
* Idle (never blocks) on the server and before hydration.
|
|
51
|
+
*/
|
|
52
|
+
export function useBlocker(shouldBlock) {
|
|
53
|
+
const [blocker, setBlocker] = useState(IDLE_BLOCKER);
|
|
54
|
+
// Track the latest predicate in a ref (updated every render) so the single registration always sees
|
|
55
|
+
// current component state without re-registering (which would drop an open prompt).
|
|
56
|
+
const shouldBlockRef = useRef(shouldBlock);
|
|
57
|
+
shouldBlockRef.current = shouldBlock;
|
|
58
|
+
useEffect(() => registerBlocker((args) => {
|
|
59
|
+
const current = shouldBlockRef.current;
|
|
60
|
+
return typeof current === "function" ? current(args) : current;
|
|
61
|
+
}, setBlocker), []);
|
|
62
|
+
return blocker;
|
|
63
|
+
}
|
|
64
|
+
//# sourceMappingURL=router.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"router.js","sourceRoot":"","sources":["../src/router.ts"],"names":[],"mappings":"AACA,OAAO,EAGL,kBAAkB,EAClB,YAAY,EAIZ,eAAe,EACf,eAAe,GAChB,MAAM,cAAc,CAAA;AACrB;;;;;;GAMG;AACH,OAAO,EAAE,aAAa,EAAE,MAAM,QAAQ,CAAA;AACtC,OAAO,EAAE,WAAW,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAA;AAIpF,2EAA2E;AAC3E,MAAM,YAAY,GAAsC,MAAM,CAAC,MAAM,CAAC,EAAE,CAAC,CAAA;AAEzE;2GAC2G;AAC3G,MAAM,CAAC,MAAM,aAAa,GAAG,aAAa,CAA0B,YAAY,CAAC,CAAA;AAEjF;;;;;;;;;;;GAWG;AACH,MAAM,UAAU,SAAS;IAGvB,OAAO,UAAU,CAAC,aAAa,CAEJ,CAAA;AAC7B,CAAC;AAED;;sEAEsE;AACtE,MAAM,UAAU,WAAW;IACzB,OAAO,WAAW,CAAC,CAAC,EAAyC,EAAE,OAAyB,EAAE,EAAE;QAC1F,MAAM,QAAQ,GAAG,kBAAkB,EAAE,CAAA;QACrC,IAAI,QAAQ,KAAK,SAAS;YAAE,OAAM;QAClC,MAAM,QAAQ,GAAG,eAAe,CAAC,EAAE,EAAE,OAAO,CAAC,CAAA;QAC7C,QAAQ,CAAC,QAAQ,CAAC,EAAE,EAAE,QAAQ,CAAC,OAAO,CAAC,CAAA;IACzC,CAAC,EAAE,EAAE,CAAqB,CAAA;AAC5B,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,UAAU,CAAC,WAAsC;IAC/D,MAAM,CAAC,OAAO,EAAE,UAAU,CAAC,GAAG,QAAQ,CAAU,YAAY,CAAC,CAAA;IAC7D,oGAAoG;IACpG,oFAAoF;IACpF,MAAM,cAAc,GAAG,MAAM,CAAC,WAAW,CAAC,CAAA;IAC1C,cAAc,CAAC,OAAO,GAAG,WAAW,CAAA;IACpC,SAAS,CACP,GAAG,EAAE,CACH,eAAe,CAAC,CAAC,IAAI,EAAE,EAAE;QACvB,MAAM,OAAO,GAAG,cAAc,CAAC,OAAO,CAAA;QACtC,OAAO,OAAO,OAAO,KAAK,UAAU,CAAC,CAAC,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,OAAO,CAAA;IAChE,CAAC,EAAE,UAAU,CAAC,EAChB,EAAE,CACH,CAAA;IACD,OAAO,OAAO,CAAA;AAChB,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nifrajs/web-preact",
|
|
3
|
-
"version": "2.
|
|
4
|
-
"description": "Preact render adapter for @nifrajs/web
|
|
3
|
+
"version": "2.3.0",
|
|
4
|
+
"description": "Preact render adapter for @nifrajs/web - streaming SSR + hydration (no build plugin).",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"bun",
|
|
7
7
|
"typescript",
|
|
@@ -36,6 +36,11 @@
|
|
|
36
36
|
"types": "./dist/fetcher.d.ts",
|
|
37
37
|
"default": "./dist/fetcher.js"
|
|
38
38
|
},
|
|
39
|
+
"./router": {
|
|
40
|
+
"bun": "./src/router.ts",
|
|
41
|
+
"types": "./dist/router.d.ts",
|
|
42
|
+
"default": "./dist/router.js"
|
|
43
|
+
},
|
|
39
44
|
"./query": {
|
|
40
45
|
"bun": "./src/query.ts",
|
|
41
46
|
"types": "./dist/query.d.ts",
|
|
@@ -55,6 +60,11 @@
|
|
|
55
60
|
"bun": "./src/content.ts",
|
|
56
61
|
"types": "./dist/content.d.ts",
|
|
57
62
|
"default": "./dist/content.js"
|
|
63
|
+
},
|
|
64
|
+
"./fn": {
|
|
65
|
+
"bun": "./src/fn.ts",
|
|
66
|
+
"types": "./dist/fn.d.ts",
|
|
67
|
+
"default": "./dist/fn.js"
|
|
58
68
|
}
|
|
59
69
|
},
|
|
60
70
|
"files": [
|
|
@@ -70,9 +80,10 @@
|
|
|
70
80
|
"access": "public"
|
|
71
81
|
},
|
|
72
82
|
"dependencies": {
|
|
73
|
-
"@nifrajs/
|
|
74
|
-
"@nifrajs/
|
|
75
|
-
"@nifrajs/
|
|
83
|
+
"@nifrajs/core": "2.3.0",
|
|
84
|
+
"@nifrajs/i18n": "2.3.0",
|
|
85
|
+
"@nifrajs/image": "2.3.0",
|
|
86
|
+
"@nifrajs/web": "2.3.0",
|
|
76
87
|
"preact-render-to-string": "^6.5.0"
|
|
77
88
|
},
|
|
78
89
|
"peerDependencies": {
|
package/src/await.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Deferred } from "@nifrajs/web"
|
|
2
2
|
import type { ComponentChildren, VNode } from "preact"
|
|
3
3
|
/**
|
|
4
|
-
* `@nifrajs/web-preact/await`
|
|
4
|
+
* `@nifrajs/web-preact/await` - the `<Await>` primitive for deferred loader data (`defer()`). Isomorphic
|
|
5
5
|
* and imports only `preact`/`preact/compat` + a type from `@nifrajs/web` (never the server renderer), so
|
|
6
6
|
* route components can use it without dragging server code into the client bundle. No JSX (the package
|
|
7
7
|
* builds with plain `tsc`).
|
package/src/client.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type MountRouterOptions, type RenderProps, searchOfChain } from "@nifrajs/web"
|
|
2
2
|
/**
|
|
3
|
-
* @nifrajs/web-preact/client
|
|
3
|
+
* @nifrajs/web-preact/client - Preact client runtime. `hydrate` hydrates a single SSR'd route;
|
|
4
4
|
* `mountRouter` hydrates a stateful Router that subscribes to the agnostic store (via
|
|
5
5
|
* `useSyncExternalStore` from preact/compat) and re-renders the matched chain on every client
|
|
6
6
|
* navigation (no full reload). Kept in its own entry so server code (preact-render-to-string) stays
|
|
@@ -11,7 +11,7 @@ import { useSyncExternalStore } from "preact/compat"
|
|
|
11
11
|
import { compose } from "./compose.ts"
|
|
12
12
|
import { setMountedRouter } from "./fetcher.ts"
|
|
13
13
|
|
|
14
|
-
// The `_error` boundary chain element
|
|
14
|
+
// The `_error` boundary chain element - defined in its own (DOM-free) module, re-exported here so
|
|
15
15
|
// nifra's client codegen resolves it from `@nifrajs/web-preact/client` alongside `mountRouter`.
|
|
16
16
|
export { errorBoundary } from "./error.ts"
|
|
17
17
|
|
|
@@ -22,20 +22,25 @@ export function hydrate(chain: readonly unknown[], props: RenderProps, container
|
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
24
|
* Hydrate a stateful Preact Router. `useSyncExternalStore` (preact/compat) subscribes to the
|
|
25
|
-
* agnostic store and re-renders the matched layout chain on each store change
|
|
25
|
+
* agnostic store and re-renders the matched layout chain on each store change - so client
|
|
26
26
|
* navigations swap routes without a full reload. Preact's compat `useSyncExternalStore` is 2-arg
|
|
27
27
|
* (no `getServerSnapshot`); `router.snapshot` is deterministic, so hydration matches the SSR markup.
|
|
28
28
|
*/
|
|
29
29
|
export function mountRouter(options: MountRouterOptions): void {
|
|
30
|
-
const { router, routes, container } = options
|
|
30
|
+
const { router, routes, searchSchemas, container } = options
|
|
31
31
|
setMountedRouter(router) // expose it to useFetcher/useFetchers (same page, client-only)
|
|
32
32
|
const Router: FunctionComponent = () => {
|
|
33
33
|
const state = useSyncExternalStore(router.subscribe, router.snapshot)
|
|
34
|
+
// Derive this route's typed `search` from the URL + the route's schema chain (the SAME `searchOfChain`
|
|
35
|
+
// the server ran), so `useSearch` reads an identical value and hydrates with no drift.
|
|
36
|
+
const q = state.path.indexOf("?")
|
|
37
|
+
const rawSearch = q === -1 ? "" : state.path.slice(q)
|
|
34
38
|
return compose(routes[state.routeId] ?? [], {
|
|
35
39
|
data: state.data,
|
|
36
40
|
actionData: state.actionData,
|
|
37
41
|
pending: state.pending,
|
|
38
|
-
|
|
42
|
+
search: searchOfChain(searchSchemas?.[state.routeId] ?? [], rawSearch),
|
|
43
|
+
// The in-flight submission (for optimistic UI) - spread only when present.
|
|
39
44
|
...(state.submission ? { submission: state.submission } : {}),
|
|
40
45
|
})
|
|
41
46
|
}
|
package/src/compose.ts
CHANGED
|
@@ -1,11 +1,26 @@
|
|
|
1
1
|
import type { RenderProps } from "@nifrajs/web"
|
|
2
|
-
import { type ComponentType, h, type VNode } from "preact"
|
|
2
|
+
import { type ComponentChildren, type ComponentType, createElement, h, type VNode } from "preact"
|
|
3
|
+
import { SearchContext } from "./router.ts"
|
|
4
|
+
|
|
5
|
+
// Frozen empty search so a render with no search context has a stable provider value.
|
|
6
|
+
const EMPTY_SEARCH: Readonly<Record<string, unknown>> = Object.freeze({})
|
|
7
|
+
|
|
8
|
+
// Preact's `createElement` overloads drop a typed `Context.Provider`'s required `value` under
|
|
9
|
+
// exactOptionalPropertyTypes (same issue i18n's provider hits); invoke it through a precisely-typed
|
|
10
|
+
// factory that states the real `{ value }` + children contract rather than scatter `any`.
|
|
11
|
+
const createSearchProvider = createElement as (
|
|
12
|
+
type: typeof SearchContext.Provider,
|
|
13
|
+
props: { value: Record<string, unknown> },
|
|
14
|
+
children?: ComponentChildren,
|
|
15
|
+
) => VNode
|
|
3
16
|
|
|
4
17
|
/**
|
|
5
18
|
* Fold a layout chain (outermost layout → page) into a single Preact tree: the page (innermost)
|
|
6
19
|
* receives `props` (the loader data); each layout wraps the child via its `children` (Preact passes
|
|
7
|
-
* the 3rd `h` arg as `props.children`).
|
|
8
|
-
* the
|
|
20
|
+
* the 3rd `h` arg as `props.children`). The whole tree is wrapped in a {@link SearchContext} provider
|
|
21
|
+
* carrying the validated `search` (threaded through `RenderProps` identically on SSR + client), so
|
|
22
|
+
* `useSearch` reads the same value on both sides - no hydration mismatch. Shared by the server adapter
|
|
23
|
+
* (renderToReadableStream) and the client (hydrate / mountRouter) - the Preact analogue of React's `compose`.
|
|
9
24
|
*/
|
|
10
25
|
export function compose(chain: readonly unknown[], props: RenderProps): VNode {
|
|
11
26
|
const last = chain.length - 1
|
|
@@ -13,10 +28,10 @@ export function compose(chain: readonly unknown[], props: RenderProps): VNode {
|
|
|
13
28
|
// loader props (typed via ComponentType<RenderProps>), then we widen `h`'s VNode<Attributes &
|
|
14
29
|
// RenderProps> to the uniform VNode the fold accumulates into. Preact's VNode<P> is invariant in P
|
|
15
30
|
// under exactOptionalPropertyTypes, so the widen needs the explicit `as VNode` (a comparability
|
|
16
|
-
// cast
|
|
31
|
+
// cast - Attributes & RenderProps is assignable to {}, so this is not an unsafe coercion).
|
|
17
32
|
let node: VNode = h(chain[last] as ComponentType<RenderProps>, props) as VNode
|
|
18
33
|
for (let i = last - 1; i >= 0; i--) {
|
|
19
|
-
// children passed as the 3rd arg
|
|
34
|
+
// children passed as the 3rd arg - Preact's canonical form (mirrors the React adapter).
|
|
20
35
|
// Each layout receives its own loader data at its own index. Layouts are the chain's leading
|
|
21
36
|
// prefix, so `layoutData[i]` belongs to `chain[i]`; anything past that end (a client-only `_error`
|
|
22
37
|
// boundary marker, the page) reads `undefined` and is unaffected.
|
|
@@ -26,5 +41,5 @@ export function compose(chain: readonly unknown[], props: RenderProps): VNode {
|
|
|
26
41
|
node,
|
|
27
42
|
) as VNode
|
|
28
43
|
}
|
|
29
|
-
return node
|
|
44
|
+
return createSearchProvider(SearchContext.Provider, { value: props.search ?? EMPTY_SEARCH }, node)
|
|
30
45
|
}
|
package/src/content.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `@nifrajs/web-preact/content`
|
|
2
|
+
* `@nifrajs/web-preact/content` - render pre-rendered HTML (e.g. a `@nifrajs/content` collection entry's
|
|
3
3
|
* `.html`) into the DOM via `dangerouslySetInnerHTML`. Pairs with content collections:
|
|
4
4
|
* `<Content html={entry.html} />`.
|
|
5
5
|
*
|
|
6
6
|
* Security: injects raw HTML, so `html` MUST be trusted (your own Markdown/content, rendered at
|
|
7
|
-
* build/server time)
|
|
7
|
+
* build/server time) - never pass user-supplied HTML without sanitizing it first.
|
|
8
8
|
*/
|
|
9
9
|
import { createElement, type JSX, type VNode } from "preact"
|
|
10
10
|
|
package/src/error.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `errorBoundary`
|
|
2
|
+
* `errorBoundary` - the Preact error-boundary chain element for nifra's `_error.tsx`. Its own module
|
|
3
3
|
* (imports only `preact`) so it's unit-testable off the DOM and the client codegen can import it from
|
|
4
4
|
* `@nifrajs/web-preact/client` (which re-exports it).
|
|
5
5
|
*/
|
|
@@ -9,7 +9,7 @@ import { Component, type ComponentChildren, createElement, type FunctionComponen
|
|
|
9
9
|
* Build an error-boundary chain element bound to `fallback` (a route's `_error` component). nifra's
|
|
10
10
|
* client codegen inserts it before the page in the matched chain; a render error in the subtree renders
|
|
11
11
|
* `fallback` with `{ data: { name, message } }` instead of crashing the app. DOM-transparent (it renders
|
|
12
|
-
* its children directly
|
|
12
|
+
* its children directly - no wrapper element), so it adds no markup and never disturbs hydration.
|
|
13
13
|
*/
|
|
14
14
|
export function errorBoundary(fallback: unknown): unknown {
|
|
15
15
|
type Props = { children?: ComponentChildren }
|
|
@@ -25,7 +25,7 @@ export function errorBoundary(fallback: unknown): unknown {
|
|
|
25
25
|
override render(): ComponentChildren {
|
|
26
26
|
const { error } = this.state
|
|
27
27
|
if (error === null) return this.props.children
|
|
28
|
-
// `fallback` is the route's `_error` default export
|
|
28
|
+
// `fallback` is the route's `_error` default export - opaque to the core, a component here.
|
|
29
29
|
const Fallback = fallback as FunctionComponent<{ data: { name: string; message: string } }>
|
|
30
30
|
return createElement(Fallback, { data: { name: error.name, message: error.message } })
|
|
31
31
|
}
|
package/src/fetcher.ts
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
import type { ClientRouter, Fetcher, FetcherState } from "@nifrajs/web"
|
|
2
2
|
/**
|
|
3
|
-
* `@nifrajs/web-preact/fetcher`
|
|
3
|
+
* `@nifrajs/web-preact/fetcher` - Preact bindings for concurrent fetchers. `useFetcher(key)` subscribes a
|
|
4
4
|
* component to an independent {@link Fetcher} (via `useSyncExternalStore` from preact/compat) and
|
|
5
5
|
* returns its reactive state plus `load`/`submit`; `useFetchers()` subscribes to the whole live
|
|
6
6
|
* collection. Imports only `preact/compat` + `@nifrajs/web` types, so route components can use it on the
|
|
7
7
|
* server *and* client. No JSX (the package builds with plain `tsc`).
|
|
8
8
|
*
|
|
9
|
-
* The router that owns the fetchers is the one `mountRouter` hydrated
|
|
9
|
+
* The router that owns the fetchers is the one `mountRouter` hydrated - it registers itself here via
|
|
10
10
|
* `setMountedRouter`. On the server (no mount) there is no router, so the hooks return an idle state
|
|
11
11
|
* (fetchers are client-only). Preact's compat `useSyncExternalStore` is 2-arg (no `getServerSnapshot`);
|
|
12
12
|
* with no router the `getSnapshot` already returns idle, so the first client render after `mountRouter`
|
|
13
|
-
* matches the SSR markup
|
|
13
|
+
* matches the SSR markup - no hydration mismatch.
|
|
14
14
|
*/
|
|
15
15
|
import { useSyncExternalStore } from "preact/compat"
|
|
16
16
|
|
|
@@ -18,7 +18,7 @@ import { useSyncExternalStore } from "preact/compat"
|
|
|
18
18
|
// page, and fetchers never exist on the server. Shared with `client.ts`'s `mountRouter`.
|
|
19
19
|
let mountedRouter: ClientRouter | undefined
|
|
20
20
|
|
|
21
|
-
/** Register (or clear) the router that owns fetchers
|
|
21
|
+
/** Register (or clear) the router that owns fetchers - called by `mountRouter`. Not for app use. */
|
|
22
22
|
export function setMountedRouter(router: ClientRouter | undefined): void {
|
|
23
23
|
mountedRouter = router
|
|
24
24
|
}
|
|
@@ -55,7 +55,7 @@ export function useFetcher(key: string): FetcherHandle {
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
/**
|
|
58
|
-
* Subscribe to the whole live fetcher collection
|
|
58
|
+
* Subscribe to the whole live fetcher collection - for a global busy view (e.g. "3 saving…"). Read
|
|
59
59
|
* each entry's `.snapshot()` for its state. Re-renders whenever any fetcher transitions or a new one
|
|
60
60
|
* is created.
|
|
61
61
|
*/
|
package/src/fn.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@nifrajs/web-preact/fn` - Preact binding for a server function's pending/error state.
|
|
3
|
+
*
|
|
4
|
+
* Calling a server function needs no hook: the client stub is `(input) => Promise<Output>`. This adds
|
|
5
|
+
* the part a component usually wants around it - in flight, last result, last error - and nothing more.
|
|
6
|
+
*
|
|
7
|
+
* The state machine is `@nifrajs/web`'s `createServerFnStore`, shared with every other adapter, so
|
|
8
|
+
* "is it pending" has one answer rather than five that drift. This file contributes only the `useSyncExternalStore` subscription.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { ClientServerFn, ServerFnReference } from "@nifrajs/web/fn"
|
|
12
|
+
import { createServerFnStore, type ServerFnState } from "@nifrajs/web/fn-state"
|
|
13
|
+
import { useMemo, useRef, useSyncExternalStore } from "preact/compat"
|
|
14
|
+
|
|
15
|
+
/** A server function's state plus the call itself. */
|
|
16
|
+
export interface ServerFnHandle<Input, Output> extends ServerFnState<Output> {
|
|
17
|
+
/** Invoke it. Records state, and rejects on failure - attach `.catch` if you only render state. */
|
|
18
|
+
readonly call: (input: Input) => Promise<Output>
|
|
19
|
+
/** Back to idle. */
|
|
20
|
+
readonly reset: () => void
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Track one server function's call state.
|
|
25
|
+
*
|
|
26
|
+
* Preact's compat `useSyncExternalStore` takes two arguments (no server snapshot); the store's idle
|
|
27
|
+
* state is shared by reference anyway, so a server render and the first client render agree.
|
|
28
|
+
*/
|
|
29
|
+
export function useServerFn<Input, Output>(
|
|
30
|
+
fn: ServerFnReference<Input, Output>,
|
|
31
|
+
): ServerFnHandle<Input, Output> {
|
|
32
|
+
// The store is created once, but always calls the CURRENT `fn`. Capturing `fn` directly would
|
|
33
|
+
// either recreate the store on every render (losing the state) or hold the first render's closure
|
|
34
|
+
// (calling a stale one); the ref is what makes "created once" and "never stale" both true.
|
|
35
|
+
const latest = useRef(fn as ClientServerFn<Input, Output>)
|
|
36
|
+
latest.current = fn as ClientServerFn<Input, Output>
|
|
37
|
+
const store = useMemo(() => createServerFnStore((input: Input) => latest.current(input)), [])
|
|
38
|
+
const state = useSyncExternalStore(store.subscribe, store.snapshot)
|
|
39
|
+
return { ...state, call: store.call, reset: store.reset }
|
|
40
|
+
}
|
package/src/i18n.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `@nifrajs/web-preact/i18n`
|
|
2
|
+
* `@nifrajs/web-preact/i18n` - Preact bindings for `@nifrajs/i18n`. `<I18nProvider locale messages>` builds a
|
|
3
3
|
* `Formatter` (memoized) and provides it; `useT()` reads it. Both `locale` + `messages` are
|
|
4
4
|
* serializable, so a loader returns them, the page renders with the negotiated catalog on the server,
|
|
5
5
|
* and the client rebuilds the same formatter from the same props (no mismatch). Uses `preact` +
|
|
6
|
-
* `preact/hooks`
|
|
6
|
+
* `preact/hooks` - no JSX.
|
|
7
7
|
*/
|
|
8
8
|
import { createFormatter, type Formatter, type Messages } from "@nifrajs/i18n"
|
|
9
9
|
import { type ComponentChildren, createContext, createElement, type VNode } from "preact"
|
|
@@ -12,7 +12,7 @@ import { useContext, useMemo } from "preact/hooks"
|
|
|
12
12
|
const I18nContext = createContext<Formatter | null>(null)
|
|
13
13
|
|
|
14
14
|
// Preact's `createElement` overloads don't reconcile a typed `Context.Provider` (required `value`)
|
|
15
|
-
// under `exactOptionalPropertyTypes`
|
|
15
|
+
// under `exactOptionalPropertyTypes` - overload resolution drops `value` and infers the props as `{}`.
|
|
16
16
|
// The call is correct at runtime (proven by the tests), so invoke it through a precisely-typed element
|
|
17
17
|
// factory that states the real contract (`{ value }` + children), rather than scatter `any`.
|
|
18
18
|
const createProvider = createElement as (
|
package/src/image.ts
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `@nifrajs/web-preact/image`
|
|
2
|
+
* `@nifrajs/web-preact/image` - a CLS-safe responsive Preact `<Image>`. A thin wrapper over `resolveImage`
|
|
3
3
|
* from `@nifrajs/image`: it computes the responsive `<img>` attributes (`src`/`srcset`/`sizes`/`width`/
|
|
4
|
-
* `height`/`loading`/`decoding`/`fetchpriority`
|
|
4
|
+
* `height`/`loading`/`decoding`/`fetchpriority` - Preact uses lowercase HTML attribute names, so it
|
|
5
5
|
* spreads `toHtmlAttrs`) and forwards any extra DOM props (`class`, `style`, `id`, `data-*`, handlers).
|
|
6
6
|
* Resizing is delegated to the `loader` (an image CDN); nifra bundles no codec. No JSX (`h`).
|
|
7
7
|
*/
|
|
8
8
|
import { type ImageLoader, type ImageProps, resolveImage, toHtmlAttrs } from "@nifrajs/image"
|
|
9
9
|
import { h, type JSX, type VNode } from "preact"
|
|
10
10
|
|
|
11
|
-
/** Attributes `resolveImage` computes
|
|
11
|
+
/** Attributes `resolveImage` computes - callers can't override them via DOM passthrough. */
|
|
12
12
|
type ComputedAttrs =
|
|
13
13
|
| "src"
|
|
14
14
|
| "srcset"
|
|
@@ -23,7 +23,7 @@ type ComputedAttrs =
|
|
|
23
23
|
export interface ImageComponentProps
|
|
24
24
|
extends ImageProps,
|
|
25
25
|
Omit<JSX.IntrinsicElements["img"], ComputedAttrs | keyof ImageProps> {
|
|
26
|
-
/** CDN URL builder. Defaults to the identity loader (no transform
|
|
26
|
+
/** CDN URL builder. Defaults to the identity loader (no transform - still CLS-safe + lazy). */
|
|
27
27
|
readonly loader?: ImageLoader
|
|
28
28
|
}
|
|
29
29
|
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { RenderAdapter } from "@nifrajs/web"
|
|
2
2
|
/**
|
|
3
|
-
* @nifrajs/web-preact
|
|
3
|
+
* @nifrajs/web-preact - the Preact render adapter for @nifrajs/web (server side). Streaming SSR via
|
|
4
4
|
* `renderToReadableStream` from `preact-render-to-string/stream` (a Web `ReadableStream<Uint8Array>`,
|
|
5
5
|
* the seam's native shape); the layout-chain fold is in `./compose`. Client hydration lives in
|
|
6
6
|
* `@nifrajs/web-preact/client`. Preact uses `h()` render functions, so no JSX/build plugin is needed
|
|
@@ -9,13 +9,13 @@ import type { RenderAdapter } from "@nifrajs/web"
|
|
|
9
9
|
import { compose } from "./compose.ts"
|
|
10
10
|
import { preactRenderToStream, preactRenderToString } from "./preact-render.ts"
|
|
11
11
|
|
|
12
|
-
/** The Preact server render adapter
|
|
12
|
+
/** The Preact server render adapter - pass to @nifrajs/web's `renderPage`. */
|
|
13
13
|
export const preactAdapter: RenderAdapter = {
|
|
14
14
|
// Synchronous one-pass render for non-deferred pages (renderPage's buffered fast path). The sync
|
|
15
15
|
// `renderToString` emits the same markup as the stream renderer without the chunking machinery.
|
|
16
16
|
//
|
|
17
17
|
// Async because the renderer module is resolved lazily (from the consumer app's node_modules under Bun
|
|
18
|
-
// runtime SSR
|
|
18
|
+
// runtime SSR - see ./preact-render). `RenderAdapter.renderToString` permits a Promise; renderPage awaits.
|
|
19
19
|
async renderToString(chain, props) {
|
|
20
20
|
const { renderToString } = await preactRenderToString()
|
|
21
21
|
return renderToString(compose(chain, props))
|
|
@@ -27,7 +27,7 @@ export const preactAdapter: RenderAdapter = {
|
|
|
27
27
|
return renderToReadableStream(compose(chain, props))
|
|
28
28
|
},
|
|
29
29
|
// Preact reconciles against the existing DOM on hydrate (like React), so there's no per-document
|
|
30
|
-
// bootstrap script
|
|
30
|
+
// bootstrap script - the seam allows the empty string (contrast Solid's generateHydrationScript).
|
|
31
31
|
hydrationHead() {
|
|
32
32
|
return ""
|
|
33
33
|
},
|
package/src/preact-render.ts
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Resolve `preact-render-to-string` (+ its `/stream` subpath) so the renderer shares ONE `preact`
|
|
3
|
-
* module-global with the route components
|
|
3
|
+
* module-global with the route components - the fix for the dual-Preact SSR crash (`undefined is not an
|
|
4
4
|
* object (evaluating '…__H')` / silently-empty hook output).
|
|
5
5
|
*
|
|
6
6
|
* WHY this exists: `preact-render-to-string` imports `options` and `h` from `preact` and mutates the
|
|
7
7
|
* shared `options` hooks; `preact/hooks` registers its render hook on the SAME `options` global. Under Bun
|
|
8
8
|
* **runtime** SSR (`nifra dev`, `nifra start`, `nifra_render`, all in-process), a static
|
|
9
9
|
* `import "preact-render-to-string"` in this adapter is resolved from THIS package's own (symlinked)
|
|
10
|
-
* node_modules
|
|
10
|
+
* node_modules - which can be a DIFFERENT physical `preact` than the consumer app's components import. Two
|
|
11
11
|
* `preact` copies → two `options` globals → the renderer walks one while `preact/hooks` wrote the other →
|
|
12
12
|
* the vnode never gets its hook-state list → the crash. Resolving the renderer from the consumer **app
|
|
13
13
|
* root** makes it pull the app's `preact` transitively, matching the components: one `options`, one core,
|
|
14
|
-
* no crash. (Empirically verified against a two-copy install fixture
|
|
14
|
+
* no crash. (Empirically verified against a two-copy install fixture - see test/dual-preact.test.ts.)
|
|
15
15
|
*
|
|
16
16
|
* Guarding precisely so the PRODUCTION/BUILT path is untouched: when `Bun.resolveSync` is unavailable
|
|
17
17
|
* (Node / Deno / Cloudflare / Vercel / any bundled output), the build has already bundled a single
|
|
@@ -31,7 +31,7 @@ export interface PreactRenderToStream {
|
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
// Narrow only the one Bun method we need (it isn't in the ambient lib types) so no `any` is introduced.
|
|
34
|
-
// `resolveSync(specifier, from)` is the only lever that re-roots a BARE specifier
|
|
34
|
+
// `resolveSync(specifier, from)` is the only lever that re-roots a BARE specifier - a runtime
|
|
35
35
|
// `Bun.plugin` onResolve does NOT fire for bare specifiers like `preact-render-to-string` (verified).
|
|
36
36
|
interface BunResolver {
|
|
37
37
|
resolveSync(specifier: string, from: string): string
|
package/src/query.ts
CHANGED
|
@@ -5,12 +5,12 @@ import {
|
|
|
5
5
|
type QueryState,
|
|
6
6
|
} from "@nifrajs/web"
|
|
7
7
|
/**
|
|
8
|
-
* `@nifrajs/web-preact/query`
|
|
8
|
+
* `@nifrajs/web-preact/query` - Preact bindings for the keyed query-cache. `useQuery(key, fn)` subscribes a
|
|
9
9
|
* component to a query (via `useSyncExternalStore` from preact/compat) and fetches on mount / key change;
|
|
10
10
|
* `useQueryClient` exposes `invalidateQueries` for after a mutation. Imports only `preact/compat` +
|
|
11
11
|
* `preact/hooks` + `@nifrajs/web`, so route components use it on the server *and* client. No JSX.
|
|
12
12
|
*
|
|
13
|
-
* The query client is created lazily **client-side only** (the `typeof window` guard)
|
|
13
|
+
* The query client is created lazily **client-side only** (the `typeof window` guard) - on the server
|
|
14
14
|
* there is none, so `useQuery` renders the idle/pending state (queries are client-first; loaders are
|
|
15
15
|
* the SSR data source). The first client render uses the same idle snapshot, so no mismatch.
|
|
16
16
|
*/
|
|
@@ -45,7 +45,7 @@ const noopAsync = async (): Promise<never> => {
|
|
|
45
45
|
|
|
46
46
|
/** A query's reactive {@link QueryState} plus `isPending` + `refetch`. */
|
|
47
47
|
export interface UseQueryResult<T> extends QueryState<T> {
|
|
48
|
-
/** `status === "pending"`
|
|
48
|
+
/** `status === "pending"` - no data yet (initial load). */
|
|
49
49
|
readonly isPending: boolean
|
|
50
50
|
/** Force a refetch (ignores `staleTime`). */
|
|
51
51
|
readonly refetch: () => Promise<T>
|
package/src/read-deferred.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `readDeferred`
|
|
2
|
+
* `readDeferred` - Preact's analogue of React 19's `use()` for a deferred promise (preact/compat has
|
|
3
3
|
* no `use`). Reads a React-style tagged thenable synchronously: returns the value if fulfilled, throws
|
|
4
4
|
* the reason if rejected, else throws the promise so `<Suspense>` awaits it. The core tags
|
|
5
5
|
* `status`/`value`/`reason` via the streamed `__nifraResolve`/`__nifraReject`; this also self-tags on
|
|
6
6
|
* settle so a server re-render (which has no `__nifraResolve`) reads "fulfilled" and stops re-throwing
|
|
7
|
-
* the already-resolved promise. Pure + isomorphic
|
|
7
|
+
* the already-resolved promise. Pure + isomorphic - no `preact` import, so it unit-tests directly.
|
|
8
8
|
*/
|
|
9
9
|
export type Thenable<T> = Promise<T> & {
|
|
10
10
|
status?: "pending" | "fulfilled" | "rejected"
|
|
@@ -28,5 +28,5 @@ export function readDeferred<T>(promise: Thenable<T>): T {
|
|
|
28
28
|
},
|
|
29
29
|
)
|
|
30
30
|
}
|
|
31
|
-
throw promise // pending
|
|
31
|
+
throw promise // pending - <Suspense> awaits it; the self-tag above wakes the re-render.
|
|
32
32
|
}
|
package/src/router.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { InferOutput, StandardSchemaV1 } from "@nifrajs/core/server"
|
|
2
|
+
import {
|
|
3
|
+
type Blocker,
|
|
4
|
+
type BlockerFunction,
|
|
5
|
+
getBrowserNavigate,
|
|
6
|
+
IDLE_BLOCKER,
|
|
7
|
+
type NavigateFunction,
|
|
8
|
+
type NavigateOptions,
|
|
9
|
+
type NavigateTargetInput,
|
|
10
|
+
registerBlocker,
|
|
11
|
+
resolveNavigate,
|
|
12
|
+
} from "@nifrajs/web"
|
|
13
|
+
/**
|
|
14
|
+
* `@nifrajs/web-preact/router` - Preact routing bindings over the agnostic `@nifrajs/web` history layer:
|
|
15
|
+
* `useNavigate` (programmatic navigation), `useBlocker` (the unsaved-changes guard), and `useSearch`
|
|
16
|
+
* (the route's typed, validated search). Navigation goes through `@nifrajs/web`'s DOM-free bridges
|
|
17
|
+
* (`getBrowserNavigate` / `registerBlocker`, populated by `installHistory`); `useSearch` reads the value
|
|
18
|
+
* `compose` provides on SSR + client mount alike. Client-safe (no `preact-render-to-string`). No JSX.
|
|
19
|
+
*/
|
|
20
|
+
import { createContext } from "preact"
|
|
21
|
+
import { useCallback, useContext, useEffect, useRef, useState } from "preact/compat"
|
|
22
|
+
|
|
23
|
+
export type { Blocker, BlockerFunction, BlockerState, NavigateFunction } from "@nifrajs/web"
|
|
24
|
+
|
|
25
|
+
// Frozen empty search so the default context value has a stable reference.
|
|
26
|
+
const EMPTY_SEARCH: Readonly<Record<string, unknown>> = Object.freeze({})
|
|
27
|
+
|
|
28
|
+
/** The current route's validated search, provided by `compose` on SSR + client mount alike (derived from
|
|
29
|
+
* the URL via the shared `searchOfChain`). `{}` outside a nifra route tree. Read via {@link useSearch}. */
|
|
30
|
+
export const SearchContext = createContext<Record<string, unknown>>(EMPTY_SEARCH)
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The route's typed, validated search params - the SAME value the loader received as `ctx.search`.
|
|
34
|
+
* SSR-correct: `compose` provides it from the URL server-side and from the identical derivation on the
|
|
35
|
+
* client mount, so a value rendered from it doesn't flash on hydration. Hostile input already failed
|
|
36
|
+
* closed to the schema's defaults at match time. Pass the route's `searchSchema` as the type argument to
|
|
37
|
+
* get its output type; bare, it's the raw parsed query (`Record<string, unknown>`).
|
|
38
|
+
*
|
|
39
|
+
* ```tsx
|
|
40
|
+
* export const searchSchema = v.object({ page: v.optional(v.fallback(v.number(), 1), 1) })
|
|
41
|
+
* const { page } = useSearch<typeof searchSchema>() // page: number
|
|
42
|
+
* ```
|
|
43
|
+
*/
|
|
44
|
+
export function useSearch<
|
|
45
|
+
Schema extends StandardSchemaV1 | undefined = undefined,
|
|
46
|
+
>(): Schema extends StandardSchemaV1 ? InferOutput<Schema> : Record<string, unknown> {
|
|
47
|
+
return useContext(SearchContext) as Schema extends StandardSchemaV1
|
|
48
|
+
? InferOutput<Schema>
|
|
49
|
+
: Record<string, unknown>
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Get the {@link NavigateFunction} (a string path, a history delta, or a typed `{ to, search }` object).
|
|
53
|
+
* Stable across renders; resolves the browser navigate at call time, so it works as soon as
|
|
54
|
+
* `installHistory` has run and no-ops before then / on the server. */
|
|
55
|
+
export function useNavigate(): NavigateFunction {
|
|
56
|
+
return useCallback((to: string | number | NavigateTargetInput, options?: NavigateOptions) => {
|
|
57
|
+
const navigate = getBrowserNavigate()
|
|
58
|
+
if (navigate === undefined) return
|
|
59
|
+
const resolved = resolveNavigate(to, options)
|
|
60
|
+
navigate(resolved.to, resolved.options)
|
|
61
|
+
}, []) as NavigateFunction
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Guard navigation away from a page with unsaved work, confirming with your OWN async UI. Mirrors
|
|
66
|
+
* react-router's `useBlocker`: pass a boolean (`useBlocker(isDirty)`) or a predicate
|
|
67
|
+
* `({ currentLocation, nextLocation }) => boolean`, and get back a {@link Blocker}. When a navigation
|
|
68
|
+
* (an anchor click, `useNavigate`, or a browser back/forward) is intercepted, `blocker.state` becomes
|
|
69
|
+
* `"blocked"` and `proceed`/`reset` go live - render a dialog and call `proceed()` to continue or
|
|
70
|
+
* `reset()` to stay put. It also arms the browser's native "Leave site?" prompt on tab close / reload.
|
|
71
|
+
* Idle (never blocks) on the server and before hydration.
|
|
72
|
+
*/
|
|
73
|
+
export function useBlocker(shouldBlock: boolean | BlockerFunction): Blocker {
|
|
74
|
+
const [blocker, setBlocker] = useState<Blocker>(IDLE_BLOCKER)
|
|
75
|
+
// Track the latest predicate in a ref (updated every render) so the single registration always sees
|
|
76
|
+
// current component state without re-registering (which would drop an open prompt).
|
|
77
|
+
const shouldBlockRef = useRef(shouldBlock)
|
|
78
|
+
shouldBlockRef.current = shouldBlock
|
|
79
|
+
useEffect(
|
|
80
|
+
() =>
|
|
81
|
+
registerBlocker((args) => {
|
|
82
|
+
const current = shouldBlockRef.current
|
|
83
|
+
return typeof current === "function" ? current(args) : current
|
|
84
|
+
}, setBlocker),
|
|
85
|
+
[],
|
|
86
|
+
)
|
|
87
|
+
return blocker
|
|
88
|
+
}
|