@real-router/preact 0.11.1 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +140 -14
  2. package/dist/cjs/index.d.ts +21 -6
  3. package/dist/cjs/index.d.ts.map +1 -1
  4. package/dist/cjs/index.js +1 -1
  5. package/dist/cjs/index.js.map +1 -1
  6. package/dist/cjs/ssr.d.ts +169 -0
  7. package/dist/cjs/ssr.d.ts.map +1 -0
  8. package/dist/cjs/ssr.js +2 -0
  9. package/dist/cjs/ssr.js.map +1 -0
  10. package/dist/cjs/useRoute-B3rj5MXo.js +2 -0
  11. package/dist/cjs/useRoute-B3rj5MXo.js.map +1 -0
  12. package/dist/esm/index.d.mts +21 -6
  13. package/dist/esm/index.d.mts.map +1 -1
  14. package/dist/esm/index.mjs +1 -1
  15. package/dist/esm/index.mjs.map +1 -1
  16. package/dist/esm/ssr.d.mts +169 -0
  17. package/dist/esm/ssr.d.mts.map +1 -0
  18. package/dist/esm/ssr.mjs +2 -0
  19. package/dist/esm/ssr.mjs.map +1 -0
  20. package/dist/esm/useRoute-BSPVVbLz.mjs +2 -0
  21. package/dist/esm/useRoute-BSPVVbLz.mjs.map +1 -0
  22. package/package.json +23 -6
  23. package/src/RouterProvider.tsx +15 -2
  24. package/src/components/Await.tsx +99 -0
  25. package/src/components/ClientOnly.tsx +25 -0
  26. package/src/components/HttpStatusCode.tsx +82 -0
  27. package/src/components/HttpStatusProvider.tsx +22 -0
  28. package/src/components/Link.tsx +52 -39
  29. package/src/components/RouteView/RouteView.tsx +12 -8
  30. package/src/components/RouteView/helpers.tsx +20 -19
  31. package/src/components/RouterErrorBoundary.tsx +28 -3
  32. package/src/components/ServerOnly.tsx +26 -0
  33. package/src/components/Streamed.tsx +24 -0
  34. package/src/context.ts +17 -0
  35. package/src/hooks/useDeferred.tsx +26 -0
  36. package/src/hooks/useIsActiveRoute.tsx +21 -13
  37. package/src/hooks/useNavigator.tsx +5 -12
  38. package/src/hooks/useRoute.tsx +7 -8
  39. package/src/hooks/useRouteNode.tsx +11 -7
  40. package/src/hooks/useRouter.tsx +5 -12
  41. package/src/ssr.ts +39 -0
  42. package/src/types.ts +2 -2
  43. package/src/useSyncExternalStore.ts +20 -0
  44. 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"}
@@ -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.11.1",
3
+ "version": "0.13.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,20 +64,21 @@
48
64
  "license": "MIT",
49
65
  "sideEffects": false,
50
66
  "dependencies": {
51
- "@real-router/core": "^0.52.0",
67
+ "@real-router/core": "^0.53.0",
52
68
  "@real-router/route-utils": "^0.2.2",
53
- "@real-router/sources": "^0.8.1"
69
+ "@real-router/sources": "^0.8.2"
54
70
  },
55
71
  "devDependencies": {
56
72
  "@testing-library/dom": "10.4.1",
57
73
  "@testing-library/jest-dom": "6.9.1",
58
74
  "@testing-library/preact": "3.2.4",
59
75
  "@testing-library/user-event": "14.6.1",
60
- "preact": "10.25.4",
61
- "@real-router/browser-plugin": "^0.17.1"
76
+ "preact": "10.29.2",
77
+ "preact-render-to-string": "6.7.0",
78
+ "@real-router/browser-plugin": "^0.17.3"
62
79
  },
63
80
  "peerDependencies": {
64
- "preact": ">=10.0.0"
81
+ "preact": ">=10.28.0 || ^11.0.0-0"
65
82
  },
66
83
  "scripts": {
67
84
  "test": "vitest",
@@ -84,17 +84,30 @@ export const RouterProvider: FunctionComponent<RouteProviderProps> = ({
84
84
  };
85
85
  }, [router, viewTransitions]);
86
86
 
87
- const navigator = useMemo(() => getNavigator(router), [router]);
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
+ }