@real-router/preact 0.11.1 → 0.12.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 +139 -13
- package/dist/cjs/index.d.ts +20 -5
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js +1 -1
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/ssr.d.ts +169 -0
- package/dist/cjs/ssr.d.ts.map +1 -0
- package/dist/cjs/ssr.js +2 -0
- package/dist/cjs/ssr.js.map +1 -0
- package/dist/cjs/useRoute-B3rj5MXo.js +2 -0
- package/dist/cjs/useRoute-B3rj5MXo.js.map +1 -0
- package/dist/esm/index.d.mts +20 -5
- package/dist/esm/index.d.mts.map +1 -1
- package/dist/esm/index.mjs +1 -1
- package/dist/esm/index.mjs.map +1 -1
- package/dist/esm/ssr.d.mts +169 -0
- package/dist/esm/ssr.d.mts.map +1 -0
- package/dist/esm/ssr.mjs +2 -0
- package/dist/esm/ssr.mjs.map +1 -0
- package/dist/esm/useRoute-BSPVVbLz.mjs +2 -0
- package/dist/esm/useRoute-BSPVVbLz.mjs.map +1 -0
- package/package.json +21 -4
- package/src/RouterProvider.tsx +15 -2
- package/src/components/Await.tsx +99 -0
- package/src/components/ClientOnly.tsx +25 -0
- package/src/components/HttpStatusCode.tsx +82 -0
- package/src/components/HttpStatusProvider.tsx +22 -0
- package/src/components/Link.tsx +53 -38
- package/src/components/RouteView/RouteView.tsx +12 -8
- package/src/components/RouteView/helpers.tsx +20 -19
- package/src/components/RouterErrorBoundary.tsx +28 -3
- package/src/components/ServerOnly.tsx +26 -0
- package/src/components/Streamed.tsx +24 -0
- package/src/context.ts +17 -0
- package/src/hooks/useDeferred.tsx +26 -0
- package/src/hooks/useIsActiveRoute.tsx +21 -13
- package/src/hooks/useNavigator.tsx +5 -12
- package/src/hooks/useRoute.tsx +7 -8
- package/src/hooks/useRouteNode.tsx +11 -7
- package/src/hooks/useRouter.tsx +5 -12
- package/src/ssr.ts +39 -0
- package/src/useSyncExternalStore.ts +20 -0
- package/src/utils/createHttpStatusSink.ts +27 -0
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { ComponentChildren } from "preact";
|
|
2
|
+
|
|
3
|
+
//#region src/components/ClientOnly.d.ts
|
|
4
|
+
interface ClientOnlyProps {
|
|
5
|
+
readonly children: ComponentChildren;
|
|
6
|
+
readonly fallback?: ComponentChildren;
|
|
7
|
+
}
|
|
8
|
+
declare function ClientOnly({
|
|
9
|
+
children,
|
|
10
|
+
fallback
|
|
11
|
+
}: ClientOnlyProps): ComponentChildren;
|
|
12
|
+
//#endregion
|
|
13
|
+
//#region src/components/ServerOnly.d.ts
|
|
14
|
+
interface ServerOnlyProps {
|
|
15
|
+
readonly children: ComponentChildren;
|
|
16
|
+
readonly fallback?: ComponentChildren;
|
|
17
|
+
}
|
|
18
|
+
declare function ServerOnly({
|
|
19
|
+
children,
|
|
20
|
+
fallback
|
|
21
|
+
}: ServerOnlyProps): ComponentChildren;
|
|
22
|
+
//#endregion
|
|
23
|
+
//#region src/components/Await.d.ts
|
|
24
|
+
interface AwaitProps<T> {
|
|
25
|
+
/** Deferred key declared in the loader's `defer({ deferred: { <name>: ... } })`. */
|
|
26
|
+
readonly name: string;
|
|
27
|
+
/** Render the resolved value. Suspends while pending; throws inside the
|
|
28
|
+
* nearest Error Boundary on rejection. */
|
|
29
|
+
readonly children: (value: T) => ComponentChildren;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Reads `useDeferred(name)` and hands the resolved value to the render-prop
|
|
33
|
+
* via Preact's `<Suspense>`-throwing convention. Wrap in `<Streamed>` (or
|
|
34
|
+
* `<Suspense>` from `preact/compat`).
|
|
35
|
+
*
|
|
36
|
+
* ```tsx
|
|
37
|
+
* <Streamed fallback={<Spinner />}>
|
|
38
|
+
* <Await<Review[]> name="reviews">
|
|
39
|
+
* {(reviews) => <ReviewList items={reviews} />}
|
|
40
|
+
* </Await>
|
|
41
|
+
* </Streamed>
|
|
42
|
+
* ```
|
|
43
|
+
*/
|
|
44
|
+
declare function Await<T = unknown>({
|
|
45
|
+
name,
|
|
46
|
+
children
|
|
47
|
+
}: AwaitProps<T>): ComponentChildren;
|
|
48
|
+
//#endregion
|
|
49
|
+
//#region src/components/Streamed.d.ts
|
|
50
|
+
interface StreamedProps {
|
|
51
|
+
/** Shown while any descendant `<Await>` / `use(promise)`-equivalent suspends. */
|
|
52
|
+
readonly fallback: ComponentChildren;
|
|
53
|
+
readonly children: ComponentChildren;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Cross-adapter alias for `<Suspense fallback={…}>` from `preact/compat`.
|
|
57
|
+
* Pairs with `<Await>` for symmetry with the React/Solid/Svelte/Vue/Angular
|
|
58
|
+
* SSR streaming naming.
|
|
59
|
+
*
|
|
60
|
+
* Preact's `Suspense` is part of `preact/compat` (experimental). For
|
|
61
|
+
* production streaming the preact-render-to-string toolchain is required.
|
|
62
|
+
*/
|
|
63
|
+
declare function Streamed({
|
|
64
|
+
fallback,
|
|
65
|
+
children
|
|
66
|
+
}: StreamedProps): ComponentChildren;
|
|
67
|
+
//#endregion
|
|
68
|
+
//#region src/components/HttpStatusCode.d.ts
|
|
69
|
+
interface HttpStatusCodeProps {
|
|
70
|
+
/** HTTP status to apply to the response. Common values: 404, 410, 451, 503. */
|
|
71
|
+
readonly code: number;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Render-time HTTP status declaration. Mount inside a route component (typical
|
|
75
|
+
* use case: a glob `*` route's NotFound page) when the status is decided by
|
|
76
|
+
* the rendered tree rather than a loader.
|
|
77
|
+
*
|
|
78
|
+
* Writes `code` to the nearest `<HttpStatusProvider>`'s sink during render and
|
|
79
|
+
* returns `null`. With no provider mounted (the standard client-side case)
|
|
80
|
+
* the component is a silent no-op — same component tree hydrates without
|
|
81
|
+
* touching the DOM or warning about mismatches.
|
|
82
|
+
*
|
|
83
|
+
* Loader-driven errors (`LoaderNotFound` → 404, `LoaderRedirect` → 30x) keep
|
|
84
|
+
* working as before; this component covers render-time decisions only.
|
|
85
|
+
*
|
|
86
|
+
* Last write wins when several `<HttpStatusCode />` instances mount in the
|
|
87
|
+
* same render pass — sink reflects the last component that ran.
|
|
88
|
+
*
|
|
89
|
+
* ```tsx
|
|
90
|
+
* // entry-server.tsx
|
|
91
|
+
* import { renderToString } from "preact-render-to-string";
|
|
92
|
+
* import { createHttpStatusSink, HttpStatusProvider } from "@real-router/preact/ssr";
|
|
93
|
+
*
|
|
94
|
+
* const sink = createHttpStatusSink();
|
|
95
|
+
* const html = renderToString(
|
|
96
|
+
* <HttpStatusProvider sink={sink}>
|
|
97
|
+
* <RouterProvider router={router}>
|
|
98
|
+
* <App />
|
|
99
|
+
* </RouterProvider>
|
|
100
|
+
* </HttpStatusProvider>,
|
|
101
|
+
* );
|
|
102
|
+
* response.status(sink.code ?? 200).send(html);
|
|
103
|
+
* ```
|
|
104
|
+
*
|
|
105
|
+
* **Streaming SSR (`renderToReadableStream`):** the response status MUST be
|
|
106
|
+
* sent before the first body byte flushes. If `<HttpStatusCode />` is mounted
|
|
107
|
+
* inside a late-resolving `<Suspense>` boundary, the sink write may happen
|
|
108
|
+
* AFTER the headers are already on the wire — the override is then lost.
|
|
109
|
+
* Mount the component in the shell (above every `<Suspense>` that could
|
|
110
|
+
* delay it). For non-streaming SSR (`renderToString` / `renderToStringAsync`)
|
|
111
|
+
* there is no such ordering concern.
|
|
112
|
+
*
|
|
113
|
+
* **Valid `code` range:** Node's `res.end()` throws `Invalid status code` on
|
|
114
|
+
* `NaN`, `0`, negative values, or values `> 999` — this surfaces as a 5xx /
|
|
115
|
+
* dropped connection, not silent corruption. Pass a real HTTP status integer
|
|
116
|
+
* (commonly 4xx/5xx; 100-999 is what Node accepts).
|
|
117
|
+
*/
|
|
118
|
+
declare function HttpStatusCode({
|
|
119
|
+
code
|
|
120
|
+
}: HttpStatusCodeProps): ComponentChildren;
|
|
121
|
+
//#endregion
|
|
122
|
+
//#region src/utils/createHttpStatusSink.d.ts
|
|
123
|
+
/**
|
|
124
|
+
* Render-scoped HTTP status sink. Created per request on the server, passed to
|
|
125
|
+
* `<HttpStatusProvider sink={...}>`, and read after `renderToString` (or the
|
|
126
|
+
* Preact streaming helper) to apply the value to the HTTP response.
|
|
127
|
+
*
|
|
128
|
+
* Last write wins: if the rendered tree mounts more than one
|
|
129
|
+
* `<HttpStatusCode />`, the value reflects the last component that ran during
|
|
130
|
+
* the render pass.
|
|
131
|
+
*
|
|
132
|
+
* No-op on the client — `<HttpStatusCode />` reads the optional context and
|
|
133
|
+
* skips the write when no provider is mounted, so the same component tree can
|
|
134
|
+
* be hydrated without changing behaviour.
|
|
135
|
+
*
|
|
136
|
+
* Constraints:
|
|
137
|
+
* - **Per-request only.** Don't share a sink across requests; the rendered
|
|
138
|
+
* tree mutates `code` in place. Module-level singletons leak status
|
|
139
|
+
* between concurrent requests.
|
|
140
|
+
* - **Don't `Object.freeze` the sink.** The component writes to `.code`;
|
|
141
|
+
* freezing makes the assignment throw under ESM strict mode.
|
|
142
|
+
*/
|
|
143
|
+
interface HttpStatusSink {
|
|
144
|
+
code: number | undefined;
|
|
145
|
+
}
|
|
146
|
+
declare function createHttpStatusSink(): HttpStatusSink;
|
|
147
|
+
//#endregion
|
|
148
|
+
//#region src/components/HttpStatusProvider.d.ts
|
|
149
|
+
interface HttpStatusProviderProps {
|
|
150
|
+
readonly sink: HttpStatusSink;
|
|
151
|
+
readonly children: ComponentChildren;
|
|
152
|
+
}
|
|
153
|
+
declare function HttpStatusProvider({
|
|
154
|
+
sink,
|
|
155
|
+
children
|
|
156
|
+
}: HttpStatusProviderProps): ComponentChildren;
|
|
157
|
+
//#endregion
|
|
158
|
+
//#region src/hooks/useDeferred.d.ts
|
|
159
|
+
/**
|
|
160
|
+
* Read a deferred promise published by `defer({ deferred: { <key>: Promise } })`
|
|
161
|
+
* inside an SSR data loader. Mirror of `@real-router/react/ssr` `useDeferred`
|
|
162
|
+
* — same `state.context.ssrDataDeferred` contract, same NEVER-on-missing
|
|
163
|
+
* fallback. Pair with `<Await>` (this package) which adds Preact-side
|
|
164
|
+
* promise-status tracking since Preact 10 has no `use(promise)` analogue.
|
|
165
|
+
*/
|
|
166
|
+
declare function useDeferred<T = unknown>(key: string): Promise<T>;
|
|
167
|
+
//#endregion
|
|
168
|
+
export { Await, type AwaitProps, ClientOnly, type ClientOnlyProps, HttpStatusCode, type HttpStatusCodeProps, HttpStatusProvider, type HttpStatusProviderProps, type HttpStatusSink, ServerOnly, type ServerOnlyProps, Streamed, type StreamedProps, createHttpStatusSink, useDeferred };
|
|
169
|
+
//# sourceMappingURL=ssr.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ssr.d.mts","names":[],"sources":["../../src/components/ClientOnly.tsx","../../src/components/ServerOnly.tsx","../../src/components/Await.tsx","../../src/components/Streamed.tsx","../../src/components/HttpStatusCode.tsx","../../src/utils/createHttpStatusSink.ts","../../src/components/HttpStatusProvider.tsx","../../src/hooks/useDeferred.tsx"],"mappings":";;;UAIiB,eAAA;EAAA,SACN,QAAA,EAAU,iBAAA;EAAA,SACV,QAAA,GAAW,iBAAA;AAAA;AAAA,iBAGN,UAAA,CAAA;EACd,QAAA;EACA;AAAA,GACC,eAAA,GAAkB,iBAAA;;;UCRJ,eAAA;EAAA,SACN,QAAA,EAAU,iBAAA;EAAA,SACV,QAAA,GAAW,iBAAA;AAAA;AAAA,iBAGN,UAAA,CAAA;EACd,QAAA;EACA;AAAA,GACC,eAAA,GAAkB,iBAAA;;;UC6CJ,UAAA;;WAEN,IAAA;EFvDqB;;EAAA,SE0DrB,QAAA,GAAW,KAAA,EAAO,CAAA,KAAM,iBAAA;AAAA;;;;;;AFrDnC;;;;;;;;iBEqEgB,KAAA,aAAA,CAAA;EACd,IAAA;EACA;AAAA,GACC,UAAA,CAAW,CAAA,IAAK,iBAAA;;;UC7EF,aAAA;;WAEN,QAAA,EAAU,iBAAA;EAAA,SACV,QAAA,EAAU,iBAAA;AAAA;;;;;;;;AHErB;iBGSgB,QAAA,CAAA;EACd,QAAA;EACA;AAAA,GACC,aAAA,GAAgB,iBAAA;;;UCfF,mBAAA;;WAEN,IAAA;AAAA;;;;;;;;;AJCX;;;;;;;;;;;;;;;;;;;;ACLA;;;;;;;;;;AAKA;;;;;;;iBG+CgB,cAAA,CAAA;EACd;AAAA,GACC,mBAAA,GAAsB,iBAAA;;;;;;AJtDzB;;;;;;;;;;AAKA;;;;;;;UKWiB,cAAA;EACf,IAAA;AAAA;AAAA,iBAGc,oBAAA,CAAA,GAAwB,cAAA;;;UCjBvB,uBAAA;EAAA,SACN,IAAA,EAAM,cAAA;EAAA,SACN,QAAA,EAAU,iBAAA;AAAA;AAAA,iBAGL,kBAAA,CAAA;EACd,IAAA;EACA;AAAA,GACC,uBAAA,GAA0B,iBAAA;;;;;;ANX7B;;;;iBOcgB,WAAA,aAAA,CAAyB,GAAA,WAAc,OAAA,CAAQ,CAAA"}
|
package/dist/esm/ssr.mjs
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import{t as e}from"./useRoute-BSPVVbLz.mjs";import{useContext as t,useEffect as n,useState as r}from"preact/hooks";import{createContext as i}from"preact";import{Suspense as a}from"preact/compat";import{jsx as o}from"preact/jsx-runtime";function s({children:e,fallback:t=null}){let[i,a]=r(!1);return n(()=>{a(!0)},[]),i?e:t}function c({children:e,fallback:t=null}){let[i,a]=r(!1);return n(()=>{a(!0)},[]),i?t:e}const l=new Promise(()=>{});function u(t){let{route:n}=e();return n.context.ssrDataDeferred?.[t]??l}function d(e){let t=e;return t.status===void 0?(t.status=`pending`,e.then(e=>{t.status===`pending`&&(t.status=`fulfilled`,t.value=e)},e=>{t.status===`pending`&&(t.status=`rejected`,t.reason=e)}),t):t}function f({name:e,children:t}){let n=u(e),r=d(n);if(r.status===`fulfilled`)return t(r.value);throw r.status===`rejected`?r.reason:n}function p({fallback:e,children:t}){return o(a,{fallback:e,children:t})}const m=i(null);function h({sink:e,children:t}){return o(m.Provider,{value:e,children:t})}function g({code:e}){let n=t(m);return n&&(process.env.NODE_ENV!==`production`&&(!Number.isInteger(e)||e<100||e>999)&&console.error(`[real-router] <HttpStatusCode code={${String(e)}} /> received an invalid HTTP status code. Node's res.end() rejects values that are not an integer in [100, 999] — pass a real HTTP status (commonly 4xx/5xx).`),n.code=e),null}function _(){return{code:void 0}}export{f as Await,s as ClientOnly,g as HttpStatusCode,h as HttpStatusProvider,c as ServerOnly,p as Streamed,_ as createHttpStatusSink,u as useDeferred};
|
|
2
|
+
//# sourceMappingURL=ssr.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ssr.mjs","names":[],"sources":["../../src/components/ClientOnly.tsx","../../src/components/ServerOnly.tsx","../../src/hooks/useDeferred.tsx","../../src/components/Await.tsx","../../src/components/Streamed.tsx","../../src/components/HttpStatusProvider.tsx","../../src/components/HttpStatusCode.tsx","../../src/utils/createHttpStatusSink.ts"],"sourcesContent":["import { useEffect, useState } from \"preact/hooks\";\n\nimport type { ComponentChildren } from \"preact\";\n\nexport interface ClientOnlyProps {\n readonly children: ComponentChildren;\n readonly fallback?: ComponentChildren;\n}\n\nexport function ClientOnly({\n children,\n fallback = null,\n}: ClientOnlyProps): ComponentChildren {\n const [mounted, setMounted] = useState(false);\n\n useEffect(() => {\n // SSR/hydration boundary: server emits the fallback branch, client matches\n // it on first paint, then this effect flips state to swap in the children.\n // The intentional re-render is what makes the markup match across renders.\n // eslint-disable-next-line @eslint-react/set-state-in-effect -- intentional post-hydration swap\n setMounted(true);\n }, []);\n\n return mounted ? children : fallback;\n}\n","import { useEffect, useState } from \"preact/hooks\";\n\nimport type { ComponentChildren } from \"preact\";\n\nexport interface ServerOnlyProps {\n readonly children: ComponentChildren;\n readonly fallback?: ComponentChildren;\n}\n\nexport function ServerOnly({\n children,\n fallback = null,\n}: ServerOnlyProps): ComponentChildren {\n const [mounted, setMounted] = useState(false);\n\n useEffect(() => {\n // SSR/hydration boundary: server emits the children branch, client matches\n // it on first paint, then this effect flips state to swap in the fallback\n // (or hide entirely). The intentional re-render keeps markup consistent\n // across renders.\n // eslint-disable-next-line @eslint-react/set-state-in-effect -- intentional post-hydration swap\n setMounted(true);\n }, []);\n\n return mounted ? fallback : children;\n}\n","import { useRoute } from \"./useRoute\";\n\ninterface DeferredContext {\n ssrDataDeferred?: Record<string, Promise<unknown>>;\n}\n\nconst NEVER_PROMISE = new Promise<never>(() => {\n // Intentionally never resolves — surfaces a forever-pending Suspense boundary\n // when a key is requested that the loader never declared.\n});\n\n/**\n * Read a deferred promise published by `defer({ deferred: { <key>: Promise } })`\n * inside an SSR data loader. Mirror of `@real-router/react/ssr` `useDeferred`\n * — same `state.context.ssrDataDeferred` contract, same NEVER-on-missing\n * fallback. Pair with `<Await>` (this package) which adds Preact-side\n * promise-status tracking since Preact 10 has no `use(promise)` analogue.\n */\nexport function useDeferred<T = unknown>(key: string): Promise<T> {\n const { route } = useRoute();\n const context = route.context as DeferredContext;\n const deferred = context.ssrDataDeferred;\n const promise = deferred?.[key];\n\n return (promise ?? NEVER_PROMISE) as Promise<T>;\n}\n","import { useDeferred } from \"../hooks/useDeferred\";\n\nimport type { ComponentChildren } from \"preact\";\n\ninterface TrackedPromise<T> extends Promise<T> {\n status?: \"pending\" | \"fulfilled\" | \"rejected\";\n value?: T;\n reason?: unknown;\n}\n\n/**\n * Preact's `Suspense` (from `preact/compat`) catches a thrown thenable and\n * re-runs the boundary's render once it settles. For deterministic re-renders\n * we tag the promise with `.status` / `.value` / `.reason` on first access so\n * the second render-pass can return the value synchronously instead of\n * throwing again.\n *\n * The same tag layout is used by React 19's internal `use(promise)` cache,\n * so promises that already carry the tag (e.g. emitted by a Suspense-aware\n * data lib) are reused as-is.\n */\nfunction track<T>(promise: Promise<T>): TrackedPromise<T> {\n const tracked = promise as TrackedPromise<T>;\n\n if (tracked.status !== undefined) {\n return tracked;\n }\n\n tracked.status = \"pending\";\n promise.then(\n (value) => {\n /* v8 ignore next 4 -- @preserve: the `.status === \"pending\"` guard\n protects against external mutation between `track()` and the .then\n microtask; covered branch is the always-true case in our control. */\n if (tracked.status === \"pending\") {\n tracked.status = \"fulfilled\";\n tracked.value = value;\n }\n },\n /* v8 ignore start -- @preserve: rejection .then handler — tested\n end-to-end via the React adapter's e2e ssr-streaming Scenario 10\n (id=4 reviews promise rejects on the wire); covering it in unit tests\n requires Preact's Suspense to surface the rejection through render,\n which doesn't compose cleanly with vitest's unhandled-rejection\n detector. Behaviour is symmetric to the success handler above. */\n (error: unknown) => {\n if (tracked.status === \"pending\") {\n tracked.status = \"rejected\";\n tracked.reason = error;\n }\n },\n /* v8 ignore stop */\n );\n\n return tracked;\n}\n\nexport interface AwaitProps<T> {\n /** Deferred key declared in the loader's `defer({ deferred: { <name>: ... } })`. */\n readonly name: string;\n /** Render the resolved value. Suspends while pending; throws inside the\n * nearest Error Boundary on rejection. */\n readonly children: (value: T) => ComponentChildren;\n}\n\n/**\n * Reads `useDeferred(name)` and hands the resolved value to the render-prop\n * via Preact's `<Suspense>`-throwing convention. Wrap in `<Streamed>` (or\n * `<Suspense>` from `preact/compat`).\n *\n * ```tsx\n * <Streamed fallback={<Spinner />}>\n * <Await<Review[]> name=\"reviews\">\n * {(reviews) => <ReviewList items={reviews} />}\n * </Await>\n * </Streamed>\n * ```\n */\nexport function Await<T = unknown>({\n name,\n children,\n}: AwaitProps<T>): ComponentChildren {\n const promise = useDeferred<T>(name);\n const tracked = track(promise);\n\n if (tracked.status === \"fulfilled\") {\n return children(tracked.value as T);\n }\n\n if (tracked.status === \"rejected\") {\n throw tracked.reason;\n }\n\n // Suspense catches the thrown thenable and waits for resolution. ESLint\n // complains because Promises aren't Errors, but Preact's Suspense (like\n // React's pre-`use()` Suspense convention) explicitly expects a thenable.\n // eslint-disable-next-line @typescript-eslint/only-throw-error -- Suspense thenable convention\n throw promise;\n}\n","import { Suspense } from \"preact/compat\";\n\nimport type { ComponentChildren } from \"preact\";\n\nexport interface StreamedProps {\n /** Shown while any descendant `<Await>` / `use(promise)`-equivalent suspends. */\n readonly fallback: ComponentChildren;\n readonly children: ComponentChildren;\n}\n\n/**\n * Cross-adapter alias for `<Suspense fallback={…}>` from `preact/compat`.\n * Pairs with `<Await>` for symmetry with the React/Solid/Svelte/Vue/Angular\n * SSR streaming naming.\n *\n * Preact's `Suspense` is part of `preact/compat` (experimental). For\n * production streaming the preact-render-to-string toolchain is required.\n */\nexport function Streamed({\n fallback,\n children,\n}: StreamedProps): ComponentChildren {\n return <Suspense fallback={fallback}>{children}</Suspense>;\n}\n","import { createContext } from \"preact\";\n\nimport type { HttpStatusSink } from \"../utils/createHttpStatusSink\";\nimport type { ComponentChildren } from \"preact\";\n\nexport const HttpStatusContext = createContext<HttpStatusSink | null>(null);\n\nexport interface HttpStatusProviderProps {\n readonly sink: HttpStatusSink;\n readonly children: ComponentChildren;\n}\n\nexport function HttpStatusProvider({\n sink,\n children,\n}: HttpStatusProviderProps): ComponentChildren {\n return (\n <HttpStatusContext.Provider value={sink}>\n {children}\n </HttpStatusContext.Provider>\n );\n}\n","import { useContext } from \"preact/hooks\";\n\nimport { HttpStatusContext } from \"./HttpStatusProvider\";\n\nimport type { ComponentChildren } from \"preact\";\n\nexport interface HttpStatusCodeProps {\n /** HTTP status to apply to the response. Common values: 404, 410, 451, 503. */\n readonly code: number;\n}\n\n/**\n * Render-time HTTP status declaration. Mount inside a route component (typical\n * use case: a glob `*` route's NotFound page) when the status is decided by\n * the rendered tree rather than a loader.\n *\n * Writes `code` to the nearest `<HttpStatusProvider>`'s sink during render and\n * returns `null`. With no provider mounted (the standard client-side case)\n * the component is a silent no-op — same component tree hydrates without\n * touching the DOM or warning about mismatches.\n *\n * Loader-driven errors (`LoaderNotFound` → 404, `LoaderRedirect` → 30x) keep\n * working as before; this component covers render-time decisions only.\n *\n * Last write wins when several `<HttpStatusCode />` instances mount in the\n * same render pass — sink reflects the last component that ran.\n *\n * ```tsx\n * // entry-server.tsx\n * import { renderToString } from \"preact-render-to-string\";\n * import { createHttpStatusSink, HttpStatusProvider } from \"@real-router/preact/ssr\";\n *\n * const sink = createHttpStatusSink();\n * const html = renderToString(\n * <HttpStatusProvider sink={sink}>\n * <RouterProvider router={router}>\n * <App />\n * </RouterProvider>\n * </HttpStatusProvider>,\n * );\n * response.status(sink.code ?? 200).send(html);\n * ```\n *\n * **Streaming SSR (`renderToReadableStream`):** the response status MUST be\n * sent before the first body byte flushes. If `<HttpStatusCode />` is mounted\n * inside a late-resolving `<Suspense>` boundary, the sink write may happen\n * AFTER the headers are already on the wire — the override is then lost.\n * Mount the component in the shell (above every `<Suspense>` that could\n * delay it). For non-streaming SSR (`renderToString` / `renderToStringAsync`)\n * there is no such ordering concern.\n *\n * **Valid `code` range:** Node's `res.end()` throws `Invalid status code` on\n * `NaN`, `0`, negative values, or values `> 999` — this surfaces as a 5xx /\n * dropped connection, not silent corruption. Pass a real HTTP status integer\n * (commonly 4xx/5xx; 100-999 is what Node accepts).\n */\nexport function HttpStatusCode({\n code,\n}: HttpStatusCodeProps): ComponentChildren {\n const sink = useContext(HttpStatusContext);\n\n if (sink) {\n // Dev-only validation: Node's `res.end()` throws `Invalid status code` on\n // NaN / 0 / negative / non-integer / >999. Surface the bad value at the\n // source so the consumer can fix the routing logic, instead of waiting\n // for the server to crash mid-response. Production builds (Vite, esbuild,\n // tsdown all replace `process.env.NODE_ENV !== \"production\"` with `false`)\n // strip the check.\n if (\n process.env.NODE_ENV !== \"production\" &&\n (!Number.isInteger(code) || code < 100 || code > 999)\n ) {\n console.error(\n `[real-router] <HttpStatusCode code={${String(code)}} /> received an invalid HTTP status code. Node's res.end() rejects values that are not an integer in [100, 999] — pass a real HTTP status (commonly 4xx/5xx).`,\n );\n }\n\n sink.code = code;\n }\n\n return null;\n}\n","/**\n * Render-scoped HTTP status sink. Created per request on the server, passed to\n * `<HttpStatusProvider sink={...}>`, and read after `renderToString` (or the\n * Preact streaming helper) to apply the value to the HTTP response.\n *\n * Last write wins: if the rendered tree mounts more than one\n * `<HttpStatusCode />`, the value reflects the last component that ran during\n * the render pass.\n *\n * No-op on the client — `<HttpStatusCode />` reads the optional context and\n * skips the write when no provider is mounted, so the same component tree can\n * be hydrated without changing behaviour.\n *\n * Constraints:\n * - **Per-request only.** Don't share a sink across requests; the rendered\n * tree mutates `code` in place. Module-level singletons leak status\n * between concurrent requests.\n * - **Don't `Object.freeze` the sink.** The component writes to `.code`;\n * freezing makes the assignment throw under ESM strict mode.\n */\nexport interface HttpStatusSink {\n code: number | undefined;\n}\n\nexport function createHttpStatusSink(): HttpStatusSink {\n return { code: undefined };\n}\n"],"mappings":"4OASA,SAAgB,EAAW,CACzB,WACA,WAAW,MAC0B,CACrC,GAAM,CAAC,EAAS,GAAc,EAAS,GAAM,CAU7C,OARA,MAAgB,CAKd,EAAW,GAAK,EACf,EAAE,CAAC,CAEC,EAAU,EAAW,ECd9B,SAAgB,EAAW,CACzB,WACA,WAAW,MAC0B,CACrC,GAAM,CAAC,EAAS,GAAc,EAAS,GAAM,CAW7C,OATA,MAAgB,CAMd,EAAW,GAAK,EACf,EAAE,CAAC,CAEC,EAAU,EAAW,EClB9B,MAAM,EAAgB,IAAI,YAAqB,GAG7C,CASF,SAAgB,EAAyB,EAAyB,CAChE,GAAM,CAAE,SAAU,GAAU,CAK5B,OAJgB,EAAM,QACG,kBACE,IAER,ECHrB,SAAS,EAAS,EAAwC,CACxD,IAAM,EAAU,EAgChB,OA9BI,EAAQ,SAAW,IAAA,IAIvB,EAAQ,OAAS,UACjB,EAAQ,KACL,GAAU,CAIL,EAAQ,SAAW,YACrB,EAAQ,OAAS,YACjB,EAAQ,MAAQ,IASnB,GAAmB,CACd,EAAQ,SAAW,YACrB,EAAQ,OAAS,WACjB,EAAQ,OAAS,IAItB,CAEM,GA7BE,EAqDX,SAAgB,EAAmB,CACjC,OACA,YACmC,CACnC,IAAM,EAAU,EAAe,EAAK,CAC9B,EAAU,EAAM,EAAQ,CAE9B,GAAI,EAAQ,SAAW,YACrB,OAAO,EAAS,EAAQ,MAAW,CAWrC,MARI,EAAQ,SAAW,WACf,EAAQ,OAOV,EC/ER,SAAgB,EAAS,CACvB,WACA,YACmC,CACnC,OAAO,EAAC,EAAD,CAAoB,WAAW,WAAoB,CAAA,CCjB5D,MAAa,EAAoB,EAAqC,KAAK,CAO3E,SAAgB,EAAmB,CACjC,OACA,YAC6C,CAC7C,OACE,EAAC,EAAkB,SAAnB,CAA4B,MAAO,EAChC,WAC0B,CAAA,CCqCjC,SAAgB,EAAe,CAC7B,QACyC,CACzC,IAAM,EAAO,EAAW,EAAkB,CAqB1C,OAnBI,IAQA,QAAQ,IAAI,WAAa,eACxB,CAAC,OAAO,UAAU,EAAK,EAAI,EAAO,KAAO,EAAO,MAEjD,QAAQ,MACN,uCAAuC,OAAO,EAAK,CAAC,gKACrD,CAGH,EAAK,KAAO,GAGP,KCxDT,SAAgB,GAAuC,CACrD,MAAO,CAAE,KAAM,IAAA,GAAW"}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import{useContext as e}from"preact/hooks";import{createContext as t}from"preact";const n=t(null),r=t(null),i=t(null);function a(t,n){return()=>{let r=e(t);if(!r)throw Error(`${n} must be used within a RouterProvider`);return r}}const o=a(n,`useRoute`),s=()=>{let e=o();if(!e.route)throw Error(`useRoute called with no active route. Did you forget to await router.start() before rendering, or is the router stopped/disposed?`);return e};export{a,r as i,i as n,n as r,s as t};
|
|
2
|
+
//# sourceMappingURL=useRoute-BSPVVbLz.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"useRoute-BSPVVbLz.mjs","names":[],"sources":["../../src/context.ts","../../src/hooks/useRoute.tsx"],"sourcesContent":["import { createContext } from \"preact\";\nimport { useContext } from \"preact/hooks\";\n\nimport type { RouteContext as RouteContextType } from \"./types\";\nimport type { Router, Navigator } from \"@real-router/core\";\nimport type { Context } from \"preact\";\n\nexport const RouteContext = createContext<RouteContextType | null>(null);\n\nexport const RouterContext = createContext<Router | null>(null);\n\nexport const NavigatorContext = createContext<Navigator | null>(null);\n\nexport function createUseContextOrThrow<T>(\n context: Context<T | null>,\n hookName: string,\n): () => T {\n return () => {\n const value = useContext(context);\n\n if (!value) {\n throw new Error(`${hookName} must be used within a RouterProvider`);\n }\n\n return value;\n };\n}\n","import { createUseContextOrThrow, RouteContext } from \"../context\";\n\nimport type { RouteContext as RouteContextType } from \"../types\";\nimport type { Params, State } from \"@real-router/core\";\n\nconst useRouteContextOrThrow = createUseContextOrThrow(\n RouteContext,\n \"useRoute\",\n);\n\nexport const useRoute = <P extends Params = Params>(): Omit<\n RouteContextType<P>,\n \"route\"\n> & { route: State<P> } => {\n const routeContext = useRouteContextOrThrow();\n\n if (!routeContext.route) {\n throw new Error(\n \"useRoute called with no active route. Did you forget to await router.start() before rendering, or is the router stopped/disposed?\",\n );\n }\n\n return routeContext as Omit<RouteContextType<P>, \"route\"> & {\n route: State<P>;\n };\n};\n"],"mappings":"iFAOA,MAAa,EAAe,EAAuC,KAAK,CAE3D,EAAgB,EAA6B,KAAK,CAElD,EAAmB,EAAgC,KAAK,CAErE,SAAgB,EACd,EACA,EACS,CACT,UAAa,CACX,IAAM,EAAQ,EAAW,EAAQ,CAEjC,GAAI,CAAC,EACH,MAAU,MAAM,GAAG,EAAS,uCAAuC,CAGrE,OAAO,GCnBX,MAAM,EAAyB,EAC7B,EACA,WACD,CAEY,MAGc,CACzB,IAAM,EAAe,GAAwB,CAE7C,GAAI,CAAC,EAAa,MAChB,MAAU,MACR,oIACD,CAGH,OAAO"}
|
package/package.json
CHANGED
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@real-router/preact",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"type": "commonjs",
|
|
5
5
|
"description": "Preact integration for Real-Router",
|
|
6
6
|
"main": "./dist/cjs/index.js",
|
|
7
7
|
"module": "./dist/esm/index.mjs",
|
|
8
8
|
"types": "./dist/esm/index.d.mts",
|
|
9
|
+
"typesVersions": {
|
|
10
|
+
"*": {
|
|
11
|
+
"ssr": [
|
|
12
|
+
"./dist/cjs/ssr.d.ts"
|
|
13
|
+
]
|
|
14
|
+
}
|
|
15
|
+
},
|
|
9
16
|
"exports": {
|
|
10
17
|
".": {
|
|
11
18
|
"@real-router/internal-source": "./src/index.ts",
|
|
@@ -15,6 +22,15 @@
|
|
|
15
22
|
},
|
|
16
23
|
"import": "./dist/esm/index.mjs",
|
|
17
24
|
"require": "./dist/cjs/index.js"
|
|
25
|
+
},
|
|
26
|
+
"./ssr": {
|
|
27
|
+
"@real-router/internal-source": "./src/ssr.ts",
|
|
28
|
+
"types": {
|
|
29
|
+
"import": "./dist/esm/ssr.d.mts",
|
|
30
|
+
"require": "./dist/cjs/ssr.d.ts"
|
|
31
|
+
},
|
|
32
|
+
"import": "./dist/esm/ssr.mjs",
|
|
33
|
+
"require": "./dist/cjs/ssr.js"
|
|
18
34
|
}
|
|
19
35
|
},
|
|
20
36
|
"files": [
|
|
@@ -48,9 +64,9 @@
|
|
|
48
64
|
"license": "MIT",
|
|
49
65
|
"sideEffects": false,
|
|
50
66
|
"dependencies": {
|
|
51
|
-
"@real-router/core": "^0.
|
|
67
|
+
"@real-router/core": "^0.53.0",
|
|
52
68
|
"@real-router/route-utils": "^0.2.2",
|
|
53
|
-
"@real-router/sources": "^0.8.
|
|
69
|
+
"@real-router/sources": "^0.8.2"
|
|
54
70
|
},
|
|
55
71
|
"devDependencies": {
|
|
56
72
|
"@testing-library/dom": "10.4.1",
|
|
@@ -58,7 +74,8 @@
|
|
|
58
74
|
"@testing-library/preact": "3.2.4",
|
|
59
75
|
"@testing-library/user-event": "14.6.1",
|
|
60
76
|
"preact": "10.25.4",
|
|
61
|
-
"
|
|
77
|
+
"preact-render-to-string": "6.6.7",
|
|
78
|
+
"@real-router/browser-plugin": "^0.17.2"
|
|
62
79
|
},
|
|
63
80
|
"peerDependencies": {
|
|
64
81
|
"preact": ">=10.0.0"
|
package/src/RouterProvider.tsx
CHANGED
|
@@ -84,17 +84,30 @@ export const RouterProvider: FunctionComponent<RouteProviderProps> = ({
|
|
|
84
84
|
};
|
|
85
85
|
}, [router, viewTransitions]);
|
|
86
86
|
|
|
87
|
-
|
|
87
|
+
// `getNavigator` is cached per-router in `@real-router/core` (WeakMap) —
|
|
88
|
+
// same router always returns the same Navigator ref. No `useMemo` needed.
|
|
89
|
+
const navigator = getNavigator(router);
|
|
90
|
+
|
|
91
|
+
// `createRouteSource` is NOT cached (per packages/sources/CLAUDE.md table).
|
|
92
|
+
// It must be stable across renders so `useSyncExternalStore`'s deps don't
|
|
93
|
+
// change identity and trigger an unsubscribe/resubscribe loop on every
|
|
94
|
+
// render. `useMemo([router])` gives one source per router-instance lifetime.
|
|
95
|
+
const store = useMemo(() => createRouteSource(router), [router]);
|
|
88
96
|
|
|
89
97
|
// useSyncExternalStore manages the router subscription lifecycle:
|
|
90
98
|
// subscribe connects to router on first listener, unsubscribes on last.
|
|
91
|
-
const store = useMemo(() => createRouteSource(router), [router]);
|
|
92
99
|
const { route, previousRoute } = useSyncExternalStore(
|
|
93
100
|
store.subscribe,
|
|
94
101
|
store.getSnapshot,
|
|
95
102
|
store.getSnapshot, // SSR: router returns same state on server and client
|
|
96
103
|
);
|
|
97
104
|
|
|
105
|
+
// Stable-ref against parent re-renders: when parent re-renders RouterProvider
|
|
106
|
+
// without a route change (e.g. consumer re-renders the root), navigator /
|
|
107
|
+
// route / previousRoute references stay identical (useSyncExternalStore +
|
|
108
|
+
// Object.is bail-out). Without `useMemo` the object literal is fresh every
|
|
109
|
+
// render, propagating spurious re-renders to every `useRoute()` consumer.
|
|
110
|
+
// The memo bails out whenever the three deps are referentially equal.
|
|
98
111
|
const routeContextValue = useMemo(
|
|
99
112
|
() => ({ navigator, route, previousRoute }),
|
|
100
113
|
[navigator, route, previousRoute],
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { useDeferred } from "../hooks/useDeferred";
|
|
2
|
+
|
|
3
|
+
import type { ComponentChildren } from "preact";
|
|
4
|
+
|
|
5
|
+
interface TrackedPromise<T> extends Promise<T> {
|
|
6
|
+
status?: "pending" | "fulfilled" | "rejected";
|
|
7
|
+
value?: T;
|
|
8
|
+
reason?: unknown;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Preact's `Suspense` (from `preact/compat`) catches a thrown thenable and
|
|
13
|
+
* re-runs the boundary's render once it settles. For deterministic re-renders
|
|
14
|
+
* we tag the promise with `.status` / `.value` / `.reason` on first access so
|
|
15
|
+
* the second render-pass can return the value synchronously instead of
|
|
16
|
+
* throwing again.
|
|
17
|
+
*
|
|
18
|
+
* The same tag layout is used by React 19's internal `use(promise)` cache,
|
|
19
|
+
* so promises that already carry the tag (e.g. emitted by a Suspense-aware
|
|
20
|
+
* data lib) are reused as-is.
|
|
21
|
+
*/
|
|
22
|
+
function track<T>(promise: Promise<T>): TrackedPromise<T> {
|
|
23
|
+
const tracked = promise as TrackedPromise<T>;
|
|
24
|
+
|
|
25
|
+
if (tracked.status !== undefined) {
|
|
26
|
+
return tracked;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
tracked.status = "pending";
|
|
30
|
+
promise.then(
|
|
31
|
+
(value) => {
|
|
32
|
+
/* v8 ignore next 4 -- @preserve: the `.status === "pending"` guard
|
|
33
|
+
protects against external mutation between `track()` and the .then
|
|
34
|
+
microtask; covered branch is the always-true case in our control. */
|
|
35
|
+
if (tracked.status === "pending") {
|
|
36
|
+
tracked.status = "fulfilled";
|
|
37
|
+
tracked.value = value;
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
/* v8 ignore start -- @preserve: rejection .then handler — tested
|
|
41
|
+
end-to-end via the React adapter's e2e ssr-streaming Scenario 10
|
|
42
|
+
(id=4 reviews promise rejects on the wire); covering it in unit tests
|
|
43
|
+
requires Preact's Suspense to surface the rejection through render,
|
|
44
|
+
which doesn't compose cleanly with vitest's unhandled-rejection
|
|
45
|
+
detector. Behaviour is symmetric to the success handler above. */
|
|
46
|
+
(error: unknown) => {
|
|
47
|
+
if (tracked.status === "pending") {
|
|
48
|
+
tracked.status = "rejected";
|
|
49
|
+
tracked.reason = error;
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
/* v8 ignore stop */
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
return tracked;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface AwaitProps<T> {
|
|
59
|
+
/** Deferred key declared in the loader's `defer({ deferred: { <name>: ... } })`. */
|
|
60
|
+
readonly name: string;
|
|
61
|
+
/** Render the resolved value. Suspends while pending; throws inside the
|
|
62
|
+
* nearest Error Boundary on rejection. */
|
|
63
|
+
readonly children: (value: T) => ComponentChildren;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Reads `useDeferred(name)` and hands the resolved value to the render-prop
|
|
68
|
+
* via Preact's `<Suspense>`-throwing convention. Wrap in `<Streamed>` (or
|
|
69
|
+
* `<Suspense>` from `preact/compat`).
|
|
70
|
+
*
|
|
71
|
+
* ```tsx
|
|
72
|
+
* <Streamed fallback={<Spinner />}>
|
|
73
|
+
* <Await<Review[]> name="reviews">
|
|
74
|
+
* {(reviews) => <ReviewList items={reviews} />}
|
|
75
|
+
* </Await>
|
|
76
|
+
* </Streamed>
|
|
77
|
+
* ```
|
|
78
|
+
*/
|
|
79
|
+
export function Await<T = unknown>({
|
|
80
|
+
name,
|
|
81
|
+
children,
|
|
82
|
+
}: AwaitProps<T>): ComponentChildren {
|
|
83
|
+
const promise = useDeferred<T>(name);
|
|
84
|
+
const tracked = track(promise);
|
|
85
|
+
|
|
86
|
+
if (tracked.status === "fulfilled") {
|
|
87
|
+
return children(tracked.value as T);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (tracked.status === "rejected") {
|
|
91
|
+
throw tracked.reason;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Suspense catches the thrown thenable and waits for resolution. ESLint
|
|
95
|
+
// complains because Promises aren't Errors, but Preact's Suspense (like
|
|
96
|
+
// React's pre-`use()` Suspense convention) explicitly expects a thenable.
|
|
97
|
+
// eslint-disable-next-line @typescript-eslint/only-throw-error -- Suspense thenable convention
|
|
98
|
+
throw promise;
|
|
99
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { useEffect, useState } from "preact/hooks";
|
|
2
|
+
|
|
3
|
+
import type { ComponentChildren } from "preact";
|
|
4
|
+
|
|
5
|
+
export interface ClientOnlyProps {
|
|
6
|
+
readonly children: ComponentChildren;
|
|
7
|
+
readonly fallback?: ComponentChildren;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function ClientOnly({
|
|
11
|
+
children,
|
|
12
|
+
fallback = null,
|
|
13
|
+
}: ClientOnlyProps): ComponentChildren {
|
|
14
|
+
const [mounted, setMounted] = useState(false);
|
|
15
|
+
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
// SSR/hydration boundary: server emits the fallback branch, client matches
|
|
18
|
+
// it on first paint, then this effect flips state to swap in the children.
|
|
19
|
+
// The intentional re-render is what makes the markup match across renders.
|
|
20
|
+
// eslint-disable-next-line @eslint-react/set-state-in-effect -- intentional post-hydration swap
|
|
21
|
+
setMounted(true);
|
|
22
|
+
}, []);
|
|
23
|
+
|
|
24
|
+
return mounted ? children : fallback;
|
|
25
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { useContext } from "preact/hooks";
|
|
2
|
+
|
|
3
|
+
import { HttpStatusContext } from "./HttpStatusProvider";
|
|
4
|
+
|
|
5
|
+
import type { ComponentChildren } from "preact";
|
|
6
|
+
|
|
7
|
+
export interface HttpStatusCodeProps {
|
|
8
|
+
/** HTTP status to apply to the response. Common values: 404, 410, 451, 503. */
|
|
9
|
+
readonly code: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Render-time HTTP status declaration. Mount inside a route component (typical
|
|
14
|
+
* use case: a glob `*` route's NotFound page) when the status is decided by
|
|
15
|
+
* the rendered tree rather than a loader.
|
|
16
|
+
*
|
|
17
|
+
* Writes `code` to the nearest `<HttpStatusProvider>`'s sink during render and
|
|
18
|
+
* returns `null`. With no provider mounted (the standard client-side case)
|
|
19
|
+
* the component is a silent no-op — same component tree hydrates without
|
|
20
|
+
* touching the DOM or warning about mismatches.
|
|
21
|
+
*
|
|
22
|
+
* Loader-driven errors (`LoaderNotFound` → 404, `LoaderRedirect` → 30x) keep
|
|
23
|
+
* working as before; this component covers render-time decisions only.
|
|
24
|
+
*
|
|
25
|
+
* Last write wins when several `<HttpStatusCode />` instances mount in the
|
|
26
|
+
* same render pass — sink reflects the last component that ran.
|
|
27
|
+
*
|
|
28
|
+
* ```tsx
|
|
29
|
+
* // entry-server.tsx
|
|
30
|
+
* import { renderToString } from "preact-render-to-string";
|
|
31
|
+
* import { createHttpStatusSink, HttpStatusProvider } from "@real-router/preact/ssr";
|
|
32
|
+
*
|
|
33
|
+
* const sink = createHttpStatusSink();
|
|
34
|
+
* const html = renderToString(
|
|
35
|
+
* <HttpStatusProvider sink={sink}>
|
|
36
|
+
* <RouterProvider router={router}>
|
|
37
|
+
* <App />
|
|
38
|
+
* </RouterProvider>
|
|
39
|
+
* </HttpStatusProvider>,
|
|
40
|
+
* );
|
|
41
|
+
* response.status(sink.code ?? 200).send(html);
|
|
42
|
+
* ```
|
|
43
|
+
*
|
|
44
|
+
* **Streaming SSR (`renderToReadableStream`):** the response status MUST be
|
|
45
|
+
* sent before the first body byte flushes. If `<HttpStatusCode />` is mounted
|
|
46
|
+
* inside a late-resolving `<Suspense>` boundary, the sink write may happen
|
|
47
|
+
* AFTER the headers are already on the wire — the override is then lost.
|
|
48
|
+
* Mount the component in the shell (above every `<Suspense>` that could
|
|
49
|
+
* delay it). For non-streaming SSR (`renderToString` / `renderToStringAsync`)
|
|
50
|
+
* there is no such ordering concern.
|
|
51
|
+
*
|
|
52
|
+
* **Valid `code` range:** Node's `res.end()` throws `Invalid status code` on
|
|
53
|
+
* `NaN`, `0`, negative values, or values `> 999` — this surfaces as a 5xx /
|
|
54
|
+
* dropped connection, not silent corruption. Pass a real HTTP status integer
|
|
55
|
+
* (commonly 4xx/5xx; 100-999 is what Node accepts).
|
|
56
|
+
*/
|
|
57
|
+
export function HttpStatusCode({
|
|
58
|
+
code,
|
|
59
|
+
}: HttpStatusCodeProps): ComponentChildren {
|
|
60
|
+
const sink = useContext(HttpStatusContext);
|
|
61
|
+
|
|
62
|
+
if (sink) {
|
|
63
|
+
// Dev-only validation: Node's `res.end()` throws `Invalid status code` on
|
|
64
|
+
// NaN / 0 / negative / non-integer / >999. Surface the bad value at the
|
|
65
|
+
// source so the consumer can fix the routing logic, instead of waiting
|
|
66
|
+
// for the server to crash mid-response. Production builds (Vite, esbuild,
|
|
67
|
+
// tsdown all replace `process.env.NODE_ENV !== "production"` with `false`)
|
|
68
|
+
// strip the check.
|
|
69
|
+
if (
|
|
70
|
+
process.env.NODE_ENV !== "production" &&
|
|
71
|
+
(!Number.isInteger(code) || code < 100 || code > 999)
|
|
72
|
+
) {
|
|
73
|
+
console.error(
|
|
74
|
+
`[real-router] <HttpStatusCode code={${String(code)}} /> received an invalid HTTP status code. Node's res.end() rejects values that are not an integer in [100, 999] — pass a real HTTP status (commonly 4xx/5xx).`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
sink.code = code;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { createContext } from "preact";
|
|
2
|
+
|
|
3
|
+
import type { HttpStatusSink } from "../utils/createHttpStatusSink";
|
|
4
|
+
import type { ComponentChildren } from "preact";
|
|
5
|
+
|
|
6
|
+
export const HttpStatusContext = createContext<HttpStatusSink | null>(null);
|
|
7
|
+
|
|
8
|
+
export interface HttpStatusProviderProps {
|
|
9
|
+
readonly sink: HttpStatusSink;
|
|
10
|
+
readonly children: ComponentChildren;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function HttpStatusProvider({
|
|
14
|
+
sink,
|
|
15
|
+
children,
|
|
16
|
+
}: HttpStatusProviderProps): ComponentChildren {
|
|
17
|
+
return (
|
|
18
|
+
<HttpStatusContext.Provider value={sink}>
|
|
19
|
+
{children}
|
|
20
|
+
</HttpStatusContext.Provider>
|
|
21
|
+
);
|
|
22
|
+
}
|