@fluixi/core 1.0.0-alpha.100

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 (253) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +334 -0
  3. package/dist/cdn/core.global.js +2 -0
  4. package/dist/cli/generate.cjs +5639 -0
  5. package/dist/cli/generate.d.ts +7 -0
  6. package/dist/cli/generate.d.ts.map +1 -0
  7. package/dist/cli/generate.js +2580 -0
  8. package/dist/cli/generate.mjs +5644 -0
  9. package/dist/cli/index.cjs +5639 -0
  10. package/dist/cli/index.d.ts +7 -0
  11. package/dist/cli/index.d.ts.map +1 -0
  12. package/dist/cli/index.js +2580 -0
  13. package/dist/cli/index.mjs +5644 -0
  14. package/dist/cli/run.cjs +64 -0
  15. package/dist/cli/run.d.ts +3 -0
  16. package/dist/cli/run.d.ts.map +1 -0
  17. package/dist/cli/run.js +62 -0
  18. package/dist/cli/run.mjs +40 -0
  19. package/dist/fluixi.d.ts +5 -0
  20. package/dist/src/cdn/chunk-RKBFEFZP.mjs +987 -0
  21. package/dist/src/cdn/core.cjs +1592 -0
  22. package/dist/src/cdn/core.d.ts +16 -0
  23. package/dist/src/cdn/core.d.ts.map +1 -0
  24. package/dist/src/cdn/core.js +15 -0
  25. package/dist/src/cdn/core.mjs +714 -0
  26. package/dist/src/cdn/router-V2THK27H.mjs +74 -0
  27. package/dist/src/index.cjs +1711 -0
  28. package/dist/src/index.d.ts +4 -0
  29. package/dist/src/index.d.ts.map +1 -0
  30. package/dist/src/index.js +11 -0
  31. package/dist/src/index.mjs +1833 -0
  32. package/dist/src/jsx-dev-runtime.cjs +26 -0
  33. package/dist/src/jsx-dev-runtime.d.ts +2 -0
  34. package/dist/src/jsx-dev-runtime.d.ts.map +1 -0
  35. package/dist/src/jsx-dev-runtime.js +1 -0
  36. package/dist/src/jsx-dev-runtime.mjs +24 -0
  37. package/dist/src/jsx-runtime.cjs +26 -0
  38. package/dist/src/jsx-runtime.d.ts +7 -0
  39. package/dist/src/jsx-runtime.d.ts.map +1 -0
  40. package/dist/src/jsx-runtime.js +8 -0
  41. package/dist/src/jsx-runtime.mjs +24 -0
  42. package/dist/src/lib/client/chunk-FDOPDB6Z.mjs +53 -0
  43. package/dist/src/lib/client/index.cjs +1210 -0
  44. package/dist/src/lib/client/index.d.ts +51 -0
  45. package/dist/src/lib/client/index.d.ts.map +1 -0
  46. package/dist/src/lib/client/index.js +174 -0
  47. package/dist/src/lib/client/index.mjs +293 -0
  48. package/dist/src/lib/client/router-4PTBPZLQ.mjs +891 -0
  49. package/dist/src/lib/context/context.cjs +81 -0
  50. package/dist/src/lib/context/context.d.ts +73 -0
  51. package/dist/src/lib/context/context.d.ts.map +1 -0
  52. package/dist/src/lib/context/context.js +93 -0
  53. package/dist/src/lib/context/context.mjs +64 -0
  54. package/dist/src/lib/context/index.cjs +83 -0
  55. package/dist/src/lib/context/index.d.ts +3 -0
  56. package/dist/src/lib/context/index.d.ts.map +1 -0
  57. package/dist/src/lib/context/index.js +1 -0
  58. package/dist/src/lib/context/index.mjs +64 -0
  59. package/dist/src/lib/control-flow.cjs +35 -0
  60. package/dist/src/lib/control-flow.d.ts +4 -0
  61. package/dist/src/lib/control-flow.d.ts.map +1 -0
  62. package/dist/src/lib/control-flow.js +1 -0
  63. package/dist/src/lib/control-flow.mjs +25 -0
  64. package/dist/src/lib/core.cjs +1710 -0
  65. package/dist/src/lib/core.d.ts +91 -0
  66. package/dist/src/lib/core.d.ts.map +1 -0
  67. package/dist/src/lib/core.js +158 -0
  68. package/dist/src/lib/core.mjs +1833 -0
  69. package/dist/src/lib/env/index.cjs +44 -0
  70. package/dist/src/lib/env/index.d.ts +26 -0
  71. package/dist/src/lib/env/index.d.ts.map +1 -0
  72. package/dist/src/lib/env/index.js +31 -0
  73. package/dist/src/lib/env/index.mjs +23 -0
  74. package/dist/src/lib/i18n/index.cjs +123 -0
  75. package/dist/src/lib/i18n/index.d.ts +110 -0
  76. package/dist/src/lib/i18n/index.d.ts.map +1 -0
  77. package/dist/src/lib/i18n/index.js +164 -0
  78. package/dist/src/lib/i18n/index.mjs +103 -0
  79. package/dist/src/lib/index.cjs +598 -0
  80. package/dist/src/lib/index.d.ts +81 -0
  81. package/dist/src/lib/index.d.ts.map +1 -0
  82. package/dist/src/lib/index.js +130 -0
  83. package/dist/src/lib/index.mjs +688 -0
  84. package/dist/src/lib/isomorphic.cjs +90 -0
  85. package/dist/src/lib/isomorphic.d.ts +114 -0
  86. package/dist/src/lib/isomorphic.d.ts.map +1 -0
  87. package/dist/src/lib/isomorphic.js +132 -0
  88. package/dist/src/lib/isomorphic.mjs +69 -0
  89. package/dist/src/lib/jsx-runtime/index.cjs +21 -0
  90. package/dist/src/lib/jsx-runtime/index.d.ts +13 -0
  91. package/dist/src/lib/jsx-runtime/index.d.ts.map +1 -0
  92. package/dist/src/lib/jsx-runtime/index.js +13 -0
  93. package/dist/src/lib/jsx-runtime/index.mjs +4 -0
  94. package/dist/src/lib/jsx-runtime/jsx-dev-runtime.cjs +21 -0
  95. package/dist/src/lib/jsx-runtime/jsx-dev-runtime.d.ts +2 -0
  96. package/dist/src/lib/jsx-runtime/jsx-dev-runtime.d.ts.map +1 -0
  97. package/dist/src/lib/jsx-runtime/jsx-dev-runtime.js +1 -0
  98. package/dist/src/lib/jsx-runtime/jsx-dev-runtime.mjs +4 -0
  99. package/dist/src/lib/plugins/fluixi-routes-plugin.cjs +302 -0
  100. package/dist/src/lib/plugins/fluixi-routes-plugin.d.ts +35 -0
  101. package/dist/src/lib/plugins/fluixi-routes-plugin.d.ts.map +1 -0
  102. package/dist/src/lib/plugins/fluixi-routes-plugin.js +118 -0
  103. package/dist/src/lib/plugins/fluixi-routes-plugin.mjs +271 -0
  104. package/dist/src/lib/plugins/index.cjs +311 -0
  105. package/dist/src/lib/plugins/index.d.ts +6 -0
  106. package/dist/src/lib/plugins/index.d.ts.map +1 -0
  107. package/dist/src/lib/plugins/index.js +7 -0
  108. package/dist/src/lib/plugins/index.mjs +279 -0
  109. package/dist/src/lib/plugins/route-codegen.cjs +73 -0
  110. package/dist/src/lib/plugins/route-codegen.d.ts +12 -0
  111. package/dist/src/lib/plugins/route-codegen.d.ts.map +1 -0
  112. package/dist/src/lib/plugins/route-codegen.js +80 -0
  113. package/dist/src/lib/plugins/route-codegen.mjs +52 -0
  114. package/dist/src/lib/plugins/route-scanner.cjs +189 -0
  115. package/dist/src/lib/plugins/route-scanner.d.ts +38 -0
  116. package/dist/src/lib/plugins/route-scanner.d.ts.map +1 -0
  117. package/dist/src/lib/plugins/route-scanner.js +215 -0
  118. package/dist/src/lib/plugins/route-scanner.mjs +158 -0
  119. package/dist/src/lib/plugins/vite.cjs +27 -0
  120. package/dist/src/lib/plugins/vite.d.ts +3 -0
  121. package/dist/src/lib/plugins/vite.d.ts.map +1 -0
  122. package/dist/src/lib/plugins/vite.js +1 -0
  123. package/dist/src/lib/plugins/vite.mjs +6 -0
  124. package/dist/src/lib/render/await.cjs +38 -0
  125. package/dist/src/lib/render/await.d.ts +26 -0
  126. package/dist/src/lib/render/await.d.ts.map +1 -0
  127. package/dist/src/lib/render/await.js +45 -0
  128. package/dist/src/lib/render/await.mjs +17 -0
  129. package/dist/src/lib/render/component.cjs +221 -0
  130. package/dist/src/lib/render/component.d.ts +296 -0
  131. package/dist/src/lib/render/component.d.ts.map +1 -0
  132. package/dist/src/lib/render/component.js +514 -0
  133. package/dist/src/lib/render/component.mjs +214 -0
  134. package/dist/src/lib/render/deferred.cjs +175 -0
  135. package/dist/src/lib/render/deferred.d.ts +36 -0
  136. package/dist/src/lib/render/deferred.d.ts.map +1 -0
  137. package/dist/src/lib/render/deferred.js +141 -0
  138. package/dist/src/lib/render/deferred.mjs +158 -0
  139. package/dist/src/lib/render/index.cjs +573 -0
  140. package/dist/src/lib/render/index.d.ts +98 -0
  141. package/dist/src/lib/render/index.d.ts.map +1 -0
  142. package/dist/src/lib/render/index.js +180 -0
  143. package/dist/src/lib/render/index.mjs +590 -0
  144. package/dist/src/lib/render/lazy.cjs +67 -0
  145. package/dist/src/lib/render/lazy.d.ts +49 -0
  146. package/dist/src/lib/render/lazy.d.ts.map +1 -0
  147. package/dist/src/lib/render/lazy.js +105 -0
  148. package/dist/src/lib/render/lazy.mjs +50 -0
  149. package/dist/src/lib/render/suspense.cjs +141 -0
  150. package/dist/src/lib/render/suspense.d.ts +65 -0
  151. package/dist/src/lib/render/suspense.d.ts.map +1 -0
  152. package/dist/src/lib/render/suspense.js +196 -0
  153. package/dist/src/lib/render/suspense.mjs +136 -0
  154. package/dist/src/lib/render/versions.cjs +35 -0
  155. package/dist/src/lib/render/versions.d.ts +2 -0
  156. package/dist/src/lib/render/versions.d.ts.map +1 -0
  157. package/dist/src/lib/render/versions.js +21 -0
  158. package/dist/src/lib/render/versions.mjs +14 -0
  159. package/dist/src/lib/router/data.cjs +228 -0
  160. package/dist/src/lib/router/data.d.ts +142 -0
  161. package/dist/src/lib/router/data.d.ts.map +1 -0
  162. package/dist/src/lib/router/data.js +258 -0
  163. package/dist/src/lib/router/data.mjs +214 -0
  164. package/dist/src/lib/router/index.cjs +921 -0
  165. package/dist/src/lib/router/index.d.ts +274 -0
  166. package/dist/src/lib/router/index.d.ts.map +1 -0
  167. package/dist/src/lib/router/index.js +904 -0
  168. package/dist/src/lib/router/index.mjs +938 -0
  169. package/dist/src/lib/router/scroll.cjs +79 -0
  170. package/dist/src/lib/router/scroll.d.ts +19 -0
  171. package/dist/src/lib/router/scroll.d.ts.map +1 -0
  172. package/dist/src/lib/router/scroll.js +68 -0
  173. package/dist/src/lib/router/scroll.mjs +58 -0
  174. package/dist/src/lib/router/transition.cjs +75 -0
  175. package/dist/src/lib/router/transition.d.ts +81 -0
  176. package/dist/src/lib/router/transition.d.ts.map +1 -0
  177. package/dist/src/lib/router/transition.js +136 -0
  178. package/dist/src/lib/router/transition.mjs +54 -0
  179. package/dist/src/lib/server/hydration.cjs +169 -0
  180. package/dist/src/lib/server/hydration.d.ts +198 -0
  181. package/dist/src/lib/server/hydration.d.ts.map +1 -0
  182. package/dist/src/lib/server/hydration.js +345 -0
  183. package/dist/src/lib/server/hydration.mjs +148 -0
  184. package/dist/src/lib/server/index.cjs +406 -0
  185. package/dist/src/lib/server/index.d.ts +115 -0
  186. package/dist/src/lib/server/index.d.ts.map +1 -0
  187. package/dist/src/lib/server/index.js +164 -0
  188. package/dist/src/lib/server/index.mjs +384 -0
  189. package/dist/src/lib/server/reactive.cjs +197 -0
  190. package/dist/src/lib/server/reactive.d.ts +239 -0
  191. package/dist/src/lib/server/reactive.d.ts.map +1 -0
  192. package/dist/src/lib/server/reactive.js +413 -0
  193. package/dist/src/lib/server/reactive.mjs +176 -0
  194. package/dist/src/lib/storage/cache.cjs +51 -0
  195. package/dist/src/lib/storage/cache.d.ts +18 -0
  196. package/dist/src/lib/storage/cache.d.ts.map +1 -0
  197. package/dist/src/lib/storage/cache.js +42 -0
  198. package/dist/src/lib/storage/cache.mjs +30 -0
  199. package/dist/src/lib/storage/index.cjs +246 -0
  200. package/dist/src/lib/storage/index.d.ts +83 -0
  201. package/dist/src/lib/storage/index.d.ts.map +1 -0
  202. package/dist/src/lib/storage/index.js +179 -0
  203. package/dist/src/lib/storage/index.mjs +231 -0
  204. package/dist/src/lib/storage/persisted.cjs +91 -0
  205. package/dist/src/lib/storage/persisted.d.ts +53 -0
  206. package/dist/src/lib/storage/persisted.d.ts.map +1 -0
  207. package/dist/src/lib/storage/persisted.js +75 -0
  208. package/dist/src/lib/storage/persisted.mjs +70 -0
  209. package/dist/src/lib/theme/color-scheme.cjs +281 -0
  210. package/dist/src/lib/theme/color-scheme.d.ts +61 -0
  211. package/dist/src/lib/theme/color-scheme.d.ts.map +1 -0
  212. package/dist/src/lib/theme/color-scheme.js +120 -0
  213. package/dist/src/lib/theme/color-scheme.mjs +265 -0
  214. package/dist/src/lib/theme/index.cjs +267 -0
  215. package/dist/src/lib/theme/index.d.ts +78 -0
  216. package/dist/src/lib/theme/index.d.ts.map +1 -0
  217. package/dist/src/lib/theme/index.js +135 -0
  218. package/dist/src/lib/theme/index.mjs +251 -0
  219. package/dist/src/lib/theme/provider.cjs +346 -0
  220. package/dist/src/lib/theme/provider.d.ts +34 -0
  221. package/dist/src/lib/theme/provider.d.ts.map +1 -0
  222. package/dist/src/lib/theme/provider.js +60 -0
  223. package/dist/src/lib/theme/provider.mjs +332 -0
  224. package/dist/src/lib/utils/index.cjs +209 -0
  225. package/dist/src/lib/utils/index.d.ts +286 -0
  226. package/dist/src/lib/utils/index.d.ts.map +1 -0
  227. package/dist/src/lib/utils/index.js +434 -0
  228. package/dist/src/lib/utils/index.mjs +188 -0
  229. package/dist/src/routes.cjs +37 -0
  230. package/dist/src/routes.d.ts +22 -0
  231. package/dist/src/routes.d.ts.map +1 -0
  232. package/dist/src/routes.js +20 -0
  233. package/dist/src/routes.mjs +17 -0
  234. package/dist/src/server.cjs +405 -0
  235. package/dist/src/server.d.ts +63 -0
  236. package/dist/src/server.d.ts.map +1 -0
  237. package/dist/src/server.js +62 -0
  238. package/dist/src/server.mjs +382 -0
  239. package/dist/src/utils.cjs +21 -0
  240. package/dist/src/utils.d.ts +10 -0
  241. package/dist/src/utils.d.ts.map +1 -0
  242. package/dist/src/utils.js +9 -0
  243. package/dist/src/utils.mjs +4 -0
  244. package/dist/src/version.generated.cjs +26 -0
  245. package/dist/src/version.generated.d.ts +3 -0
  246. package/dist/src/version.generated.d.ts.map +1 -0
  247. package/dist/src/version.generated.js +3 -0
  248. package/dist/src/version.generated.mjs +5 -0
  249. package/dist/tsconfig.lib.tsbuildinfo +1 -0
  250. package/package.json +164 -0
  251. package/src/index.ts +19 -0
  252. package/types/fluixi.d.ts +5 -0
  253. package/types/vinxi.d.ts +50 -0
@@ -0,0 +1,904 @@
1
+ /**
2
+ *
3
+ * Renders the resolved match tree TOP-DOWN. Each layer's component is rendered
4
+ * inside the PARENT layer's <Outlet/> scope, so a child route inherits whatever
5
+ * context a parent layout provides. <Outlet/> is reactive but keyed on the child's
6
+ * component reference, so it only re-renders when the child route at that depth
7
+ * actually changes: sibling navigation reuses the layout, deeper navigation
8
+ * leaves shallower layouts untouched.
9
+ *
10
+ * No parentGate, no deferred content memo: a child is rendered straight from the
11
+ * matched array, never by re-entering a still-building parent, so deeply nested
12
+ * <Outlet/> works at any depth.
13
+ */
14
+ import { createSignal, createMemo, createResource, createEffect, createRoot, getOwner, onCleanup, runWithOwner, untrack, flush, } from '@fluixi/reactive/signal';
15
+ import { prepareComponents, runTransition, } from './transition.js';
16
+ import { insert, createComponent, createNativeElement, setAttribute, } from '@fluixi/dom';
17
+ // Reactive context (not fluixi createProvider, which destructures children
18
+ // eagerly) so a `get children()` getter renders after the context is stamped.
19
+ import { createContext, useContext } from '@fluixi/reactive/signal';
20
+ import { get } from '@fluixi/utils/object';
21
+ import { deepEqual } from '@fluixi/utils/compare';
22
+ import { keepScroll } from './scroll.js';
23
+ import { createRouter as createCoreRouter, createBrowserHistory, matchRoutes, matchPath, } from '@fluixi/router';
24
+ const RouterContext = createContext(undefined, 'router');
25
+ const OutletContext = createContext(undefined, 'outlet');
26
+ const LevelParamsContext = createContext(undefined, 'route-params');
27
+ const RouteDataContext = createContext(undefined, 'route-data');
28
+ /** Render one matched layer: load its data, stamp its contexts, run its component. */
29
+ function renderLayer(m, depth, base) {
30
+ // Load this layer's data once. createResource integrates with the nearest
31
+ // Suspense; it is owned by the enclosing memo, so it is recreated only when this
32
+ // layer's route changes (not on deeper navigation).
33
+ let data;
34
+ const loader = m.route.data;
35
+ if (typeof loader === 'function') {
36
+ let cancelled = false;
37
+ onCleanup(() => {
38
+ cancelled = true;
39
+ });
40
+ const owner = getOwner();
41
+ const router = useContext(RouterContext);
42
+ // The route's `routeDeps`, once its module has loaded: a change loads the layer again.
43
+ let watching = false;
44
+ const trackDeps = (deps) => {
45
+ if (watching || cancelled)
46
+ return;
47
+ watching = true;
48
+ runWithOwner(owner, () => {
49
+ let first = true;
50
+ createEffect(() => {
51
+ deps();
52
+ if (first)
53
+ first = false;
54
+ else
55
+ untrack(() => void refetch());
56
+ });
57
+ });
58
+ };
59
+ // Whether the loader looked at the location: only then does a change of query string,
60
+ // which keeps this layer, concern it. Unknown until it has run here: a page hydrated from
61
+ // the server got its data without running it.
62
+ let readsLocation;
63
+ const [resource, { refetch }] = createResource(() => {
64
+ // The params and location this load is for, fixed now. Accessors read later would see
65
+ // the next navigation's: a lazy route reads them after its module resolves, and a
66
+ // reload a revalidation started just before a navigation then ran with the next page's
67
+ // params (an empty id).
68
+ const params = untrack(base.params);
69
+ const location = untrack(base.location);
70
+ readsLocation = false;
71
+ const result = loader({
72
+ params: () => params,
73
+ location: () => {
74
+ readsLocation = true;
75
+ return location;
76
+ },
77
+ }, owner, () => cancelled, trackDeps);
78
+ return router ? router.trackLoad(result) : result;
79
+ });
80
+ // A revalidated cache entry: load again. The page stays on screen with its current data.
81
+ // Not a page on its way out (another route matched at this depth, kept until the next one
82
+ // has its data): its loader would run with the params of the page replacing it.
83
+ onCleanup(onRevalidate(() => {
84
+ if (untrack(() => base.matched()[depth]?.route) !== m.route)
85
+ return;
86
+ void refetch();
87
+ }));
88
+ // A navigation that changes only this layer's params, or only the query string, keeps the
89
+ // layer, so nothing else would load its data for them. Params compared by value: `matched`
90
+ // is rebuilt on every navigation. The query counts only for a loader that read the
91
+ // location; the hash never does.
92
+ let loadedFor = untrack(() => JSON.stringify(base.matched()[depth]?.params ?? {}));
93
+ let searchedFor = untrack(() => base.location().search);
94
+ createEffect(() => {
95
+ const now = base.matched()[depth];
96
+ const search = base.location().search;
97
+ // Another route at this depth: this page is on its way out, kept only until the next
98
+ // one has its data, and the new params are not for it.
99
+ if (now?.route !== m.route)
100
+ return;
101
+ const params = JSON.stringify(now.params ?? {});
102
+ const paramsMoved = params !== loadedFor;
103
+ const searchMoved = readsLocation !== false && search !== searchedFor;
104
+ loadedFor = params;
105
+ searchedFor = search;
106
+ if (paramsMoved || searchMoved)
107
+ untrack(() => void refetch());
108
+ });
109
+ // A failed load throws where the page reads it, so the nearest ErrorBoundary shows it.
110
+ // A resource reads as undefined once it has failed, which left the page empty and gave
111
+ // nothing a chance to catch the error.
112
+ data = () => {
113
+ const error = resource.error;
114
+ if (error !== undefined && error !== null)
115
+ throw error;
116
+ // Loading again (new params, a revalidation): the page keeps its current data. Reading
117
+ // the resource itself would suspend, and a Suspense boundary above would take the page
118
+ // out of the document until the data arrives, losing what the reader had in it.
119
+ const r = resource;
120
+ if (r.state === 'refreshing')
121
+ return r.latest;
122
+ return resource();
123
+ };
124
+ Object.defineProperty(data, 'loading', { get: () => resource.loading });
125
+ Object.defineProperty(data, 'state', { get: () => resource.state });
126
+ }
127
+ const buildPage = () => RouteDataContext.Provider({
128
+ value: data,
129
+ get children() {
130
+ return LevelParamsContext.Provider({
131
+ value: m.params,
132
+ get children() {
133
+ return OutletContext.Provider({
134
+ value: { depth, ...base },
135
+ get children() {
136
+ return createComponent(m.component, {});
137
+ },
138
+ });
139
+ },
140
+ });
141
+ },
142
+ });
143
+ if (!data)
144
+ return { view: buildPage(), ready: () => true };
145
+ // Gate on the data being resolved, like the old router, so the page reads
146
+ // ready data (rd() returns the value, not a thrown promise) and works without a
147
+ // Suspense boundary. Signal-gated (`ready` only flips when loading actually
148
+ // changes) so navigation re-renders don't re-run this and rebuild the page.
149
+ const res = data;
150
+ // The first load only. A refetch after revalidate keeps the page, showing the old data until the new arrives.
151
+ const isLoading = () => {
152
+ const state = res.state;
153
+ return state === undefined ? res.loading : state === 'pending' || state === 'unresolved';
154
+ };
155
+ // untrack the initial read so the enclosing render memo doesn't subscribe to the
156
+ // resource state (that would re-run renderLayer and recreate the resource, a loop).
157
+ const [ready, setReady] = createSignal(untrack(() => !isLoading()));
158
+ createEffect(() => setReady(!isLoading()));
159
+ return { view: createMemo(() => (ready() ? buildPage() : null)), ready };
160
+ }
161
+ /**
162
+ * Renders the next matched layer, reactively but keyed on its component ref so the
163
+ * child is only rebuilt when the route at this depth changes. Used by both <Outlet/>
164
+ * and the Router root.
165
+ */
166
+ function renderChild(ctx) {
167
+ const childIdx = ctx.depth + 1;
168
+ // Gate rendering on the child component reference through a SIGNAL, not a direct
169
+ // memo read. This reactive core marks the whole downstream memo chain stale on any
170
+ // signal write (no value check), so a memo that read `matched` would re-run on
171
+ // every navigation and re-render the child. A signal only notifies when its value
172
+ // actually changes, so the render memo below re-runs only when the route at this
173
+ // depth changes: sibling navigation reuses the layout, deeper navigation leaves
174
+ // this level untouched. Each child renders in a root attached to this owner (below),
175
+ // so it stays connected to the Suspense / context owner chain.
176
+ const [childComp, setChildComp] = createSignal(untrack(() => ctx.matched()[childIdx]?.component));
177
+ createEffect(() => {
178
+ const c = ctx.matched()[childIdx]?.component;
179
+ // `matched` is invalidated more than once per navigation, so this effect runs more
180
+ // than once. Writing only on a real change keeps the redundant runs from touching
181
+ // the graph at all.
182
+ if (untrack(childComp) !== c)
183
+ setChildComp(() => c);
184
+ });
185
+ // Each layer lives in its own root under this Outlet's owner: attached, so context and
186
+ // Suspense reach it, but not owned by the memo below, so a newer layer does not dispose it.
187
+ // That is what lets the page on screen stay there while the next one loads its data.
188
+ const owner = getOwner();
189
+ let shown = null;
190
+ let pending = null;
191
+ const drop = (layer) => layer?.dispose();
192
+ onCleanup(() => {
193
+ drop(shown);
194
+ drop(pending);
195
+ shown = pending = null;
196
+ });
197
+ const incoming = createMemo(() => {
198
+ const component = childComp();
199
+ if (!component)
200
+ return null;
201
+ const m = untrack(() => ctx.matched())[childIdx];
202
+ return runWithOwner(owner, () => createRoot((dispose) => ({
203
+ ...renderLayer(m, childIdx, { matched: ctx.matched, params: ctx.params, location: ctx.location }),
204
+ dispose,
205
+ })));
206
+ });
207
+ return createMemo(() => {
208
+ const next = incoming();
209
+ if (!next) {
210
+ drop(shown);
211
+ drop(pending);
212
+ shown = pending = null;
213
+ // No route at this depth. At the root (the only ctx carrying `notFound`) that means the
214
+ // URL matched nothing: render the not-found fallback instead of a blank page. Nested
215
+ // Outlets have no `notFound`, so they stay null (a missing child is just an empty Outlet).
216
+ return ctx.notFound ? ctx.notFound() : null;
217
+ }
218
+ if (next === shown)
219
+ return shown.view;
220
+ // A newer navigation superseded the one still loading.
221
+ if (pending && pending !== next)
222
+ drop(pending);
223
+ pending = next;
224
+ // Keep the current page until the next one has its data. Nothing on screen yet (first
225
+ // render, hydration): show the next one straight away, its own gate handles loading.
226
+ if (shown && !next.ready())
227
+ return shown.view;
228
+ drop(shown);
229
+ shown = next;
230
+ pending = null;
231
+ return shown.view;
232
+ });
233
+ }
234
+ /**
235
+ * Resolve the Router's `notFound` prop to a renderable view. A component is instantiated; an
236
+ * element/value is returned as-is; when omitted, a minimal built-in 404 keeps the page from
237
+ * rendering blank. Prefer a catch-all file route (`[...slug].tsx` -> `*`) when the 404 should sit
238
+ * inside your layout, this prop is the app-level fallback for an otherwise unmatched URL.
239
+ */
240
+ function renderNotFound(notFound) {
241
+ if (notFound == null)
242
+ return buildDefaultNotFound();
243
+ return typeof notFound === 'function' ? createComponent(notFound, {}) : notFound;
244
+ }
245
+ /** Minimal, unstyled-enough default 404 (single element so it hydrates cleanly). */
246
+ function buildDefaultNotFound() {
247
+ const el = createNativeElement('div');
248
+ setAttribute(el, 'class', 'fx-not-found');
249
+ setAttribute(el, 'role', 'main');
250
+ setAttribute(el, 'style', 'min-height:60vh;display:grid;place-content:center;gap:.25rem;text-align:center;' +
251
+ 'font-family:system-ui,-apple-system,sans-serif');
252
+ const h1 = createNativeElement('h1');
253
+ setAttribute(h1, 'style', 'margin:0;font-size:2.5rem;font-weight:700');
254
+ insert(h1, '404');
255
+ const p = createNativeElement('p');
256
+ setAttribute(p, 'style', 'margin:0;opacity:.7');
257
+ insert(p, 'This page could not be found.');
258
+ el.appendChild(h1);
259
+ el.appendChild(p);
260
+ return el;
261
+ }
262
+ /**
263
+ * The presentation for the navigation about to happen, handed from `useNavigate` to the
264
+ * subscriber in `Router`. Read and cleared within the same synchronous call.
265
+ */
266
+ let pendingTransitionMode;
267
+ /**
268
+ * The router. Matches the current URL against `routes` and renders what matches.
269
+ *
270
+ * Supplies the router context everything below reaches through `useRouter`,
271
+ * `useNavigate` and `Link`, so those only work inside it. `history` defaults to a
272
+ * browser history; pass a memory history for tests or a server render. An unmatched URL
273
+ * renders `notFound`, which defaults to a minimal built-in page so a bad URL is never
274
+ * blank. For a 404 that keeps your layout, add a catch-all file route instead.
275
+ *
276
+ * @example
277
+ * ```ts
278
+ * html`<${Router} routes=${routes} base="/app" />`;
279
+ * ```
280
+ */
281
+ export function Router(props) {
282
+ const history = props.history ?? createBrowserHistory();
283
+ const router = createCoreRouter({
284
+ routes: props.routes,
285
+ base: props.base,
286
+ history,
287
+ });
288
+ const [state, setState] = createSignal(router.state());
289
+ // A navigation in flight, and the layer data loads it started: together they are
290
+ // `useIsRouting()`, what a progress bar reads.
291
+ const [navigating, setNavigating] = createSignal(false);
292
+ const [loads, setLoads] = createSignal(0);
293
+ /**
294
+ * Which navigation is allowed to commit.
295
+ *
296
+ * Preparation is async, so two navigations can be in flight at once: A→B starts
297
+ * loading B's chunk, the reader clicks through to C before it lands. Each navigation
298
+ * takes the next number and checks it is still the current one before writing the
299
+ * match signal, so B resolving late finds itself superseded and returns without
300
+ * touching anything. Without this, B would commit over C, the URL would say /c and
301
+ * the page would show B.
302
+ */
303
+ let navigationId = 0;
304
+ // The window's scroll, for a browser history: where each navigation lands once its page is
305
+ // shown. `arrived` is the navigation whose page was last committed.
306
+ const scroll = history.window ? keepScroll(router.state().location) : undefined;
307
+ let arriving = scroll ? { id: 0, location: router.state().location, from: undefined } : null;
308
+ const [arrived, setArrived] = createSignal(0);
309
+ /**
310
+ * How to present the next commit, set by `useNavigate` immediately before it calls
311
+ * into the core router.
312
+ *
313
+ * A module-level hand-off rather than a parameter because the core router's
314
+ * `navigate` does not carry options through to its listeners, and it notifies
315
+ * synchronously: the value is written and read within the same call, never left
316
+ * lying around for an unrelated navigation to pick up.
317
+ */
318
+ const takeMode = () => {
319
+ const mode = pendingTransitionMode ?? (props.transition ?? 'auto');
320
+ pendingTransitionMode = undefined;
321
+ return mode;
322
+ };
323
+ const unsub = router.subscribe((next) => {
324
+ const id = ++navigationId;
325
+ const mode = takeMode();
326
+ setNavigating(true);
327
+ if (scroll) {
328
+ scroll.leave();
329
+ arriving = { id, location: next.location, from: untrack(state).location };
330
+ }
331
+ // Load the modules the next route needs before anything is torn down, so a lazy
332
+ // route renders straight through instead of suspending into a fallback with the
333
+ // old page already gone.
334
+ const prepared = prepareComponents((next.match?.matched ?? []).map((m) => m.component).filter(Boolean));
335
+ const commit = () => {
336
+ setState(next);
337
+ // The write only queues; effects are scheduled. Flushing here is what lets the
338
+ // browser snapshot a settled DOM rather than a half-built one. The layers built by
339
+ // the flush have registered their data loads, so `isRouting` stays true through them.
340
+ const flushed = flush();
341
+ if (id === navigationId)
342
+ setNavigating(false);
343
+ if (scroll)
344
+ setArrived(id);
345
+ return flushed;
346
+ };
347
+ if (!prepared) {
348
+ // Nothing to wait for: stay on the tick this navigation was already on.
349
+ void runTransition(mode, commit);
350
+ return;
351
+ }
352
+ void prepared.then(() => {
353
+ // Superseded while the module loaded. The newer navigation owns the DOM now.
354
+ if (id !== navigationId)
355
+ return;
356
+ return runTransition(mode, commit);
357
+ });
358
+ });
359
+ onCleanup(() => {
360
+ unsub();
361
+ scroll?.dispose();
362
+ router.destroy();
363
+ // Anything still preparing is now stale, so nothing can commit into a torn-down
364
+ // router.
365
+ navigationId++;
366
+ });
367
+ // Drop pathless layers (no component), their params still appear in the merged
368
+ // params exposed by RouterContext.
369
+ const matched = createMemo(() => (state().match?.matched ?? []).filter((m) => m.component));
370
+ const ctx = {
371
+ router: router,
372
+ location: createMemo(() => state().location),
373
+ params: createMemo(() => state().match?.params ?? {}),
374
+ isRouting: () => navigating() || loads() > 0,
375
+ // Counted by the promise, not by an owner: a load a newer navigation superseded still
376
+ // settles and uncounts itself, and a resource seeded from the server never calls it.
377
+ trackLoad(result) {
378
+ if (result && typeof result.finally === 'function') {
379
+ setLoads((n) => n + 1);
380
+ void result.finally(() => setLoads((n) => n - 1)).catch(() => { });
381
+ }
382
+ return result;
383
+ },
384
+ };
385
+ // Scroll once the page is on screen: after its modules and its data, which is when the
386
+ // router stops routing, and a frame later, before that frame is painted. The page's own
387
+ // effects fill it in after the router's (an article set through innerHTML), and a scroll
388
+ // into a page not yet filled is cut short to its height. A newer navigation takes over
389
+ // from one still loading.
390
+ if (scroll) {
391
+ const nextFrame = typeof requestAnimationFrame === 'function' ? requestAnimationFrame : (fn) => setTimeout(fn, 0);
392
+ createEffect(() => {
393
+ const id = arrived();
394
+ if (ctx.isRouting())
395
+ return;
396
+ const next = arriving;
397
+ if (!next || next.id !== id || id !== navigationId)
398
+ return;
399
+ arriving = null;
400
+ nextFrame(() => {
401
+ if (id === navigationId)
402
+ scroll.arrive(next.location, next.from);
403
+ });
404
+ });
405
+ }
406
+ // Render depth 0 (a virtual parent at depth -1 so the root reuses the same path).
407
+ const root = {
408
+ depth: -1,
409
+ matched,
410
+ params: ctx.params,
411
+ location: ctx.location,
412
+ // Only the root carries this: renderChild shows it when the URL matched nothing.
413
+ notFound: () => renderNotFound(props.notFound),
414
+ };
415
+ // Build the root view once, lazily, inside the provider's children getter: the
416
+ // getter must run in the provider scope (so components can useRouter), but the
417
+ // reactive provider re-invokes it whenever content changes: caching keeps the
418
+ // root chain from being rebuilt on every navigation.
419
+ let rootView;
420
+ return RouterContext.Provider({
421
+ value: ctx,
422
+ get children() {
423
+ return (rootView ??= renderChild(root));
424
+ },
425
+ });
426
+ }
427
+ // ─── Outlet ───────────────────────────────────────────────────────────────────
428
+ /** Renders this layout's child route, inside this layout's context. */
429
+ export function Outlet() {
430
+ const ctx = useContext(OutletContext);
431
+ if (!ctx)
432
+ return null;
433
+ return renderChild(ctx);
434
+ }
435
+ /**
436
+ * Whether a navigation is in progress: from the moment it starts until the route it lands on
437
+ * has loaded its modules and its route data. For a progress bar or a busy cursor.
438
+ *
439
+ * `delay` holds the `true` back until the navigation has lasted that many milliseconds, so a
440
+ * navigation that settles at once shows nothing. It still turns `false` the moment it ends.
441
+ *
442
+ * @example
443
+ * ```tsx
444
+ * const routing = useIsRouting({ delay: 120 });
445
+ * <div class="progress" data-active={() => (routing() ? '' : undefined)} />
446
+ * ```
447
+ */
448
+ export function useIsRouting(options = {}) {
449
+ const routing = useContext(RouterContext)?.isRouting ?? (() => false);
450
+ const delay = options.delay ?? 0;
451
+ if (delay <= 0)
452
+ return routing;
453
+ const [late, setLate] = createSignal(false);
454
+ createEffect(() => {
455
+ if (!routing()) {
456
+ setLate(false);
457
+ return;
458
+ }
459
+ const timer = setTimeout(() => setLate(true), delay);
460
+ onCleanup(() => clearTimeout(timer));
461
+ });
462
+ return () => routing() && late();
463
+ }
464
+ /** Navigate to `to` on mount (replace by default). Renders nothing. */
465
+ export function Redirect(props) {
466
+ const ctx = useContext(RouterContext);
467
+ // Nothing to do without a router, and nothing to navigate on the server.
468
+ if (ctx && typeof window !== 'undefined') {
469
+ // Navigate after the current render commits. Navigating during render (an effect
470
+ // running mid-render) updated the location but the route never re-matched, so the
471
+ // target never showed. Guard against firing if this Redirect is torn down first.
472
+ let cancelled = false;
473
+ onCleanup(() => (cancelled = true));
474
+ queueMicrotask(() => {
475
+ if (!cancelled)
476
+ ctx.router.navigate(props.to, { replace: props.replace !== false });
477
+ });
478
+ }
479
+ return null;
480
+ }
481
+ // ─── Hooks ──────────────────────────────────────────────────────────────────
482
+ // Safe stub returned by useRouter on the SERVER when no RouterContext is on the owner
483
+ // chain. That happens during a late/detached re-render: e.g. a slow data resource
484
+ // settling after renderToStringAsync already serialized the response and disposed the
485
+ // root: whose output is discarded. Returning this (instead of throwing) prevents a
486
+ // floating rejection that crashes the dev server / shows an error overlay seconds
487
+ // after the page already rendered.
488
+ const _ssrRouterStub = {
489
+ router: { navigate: () => { } },
490
+ location: () => ({ pathname: '', search: '', hash: '', query: {} }),
491
+ params: () => ({}),
492
+ isRouting: () => false,
493
+ trackLoad: (result) => result,
494
+ };
495
+ /**
496
+ * The router context: the router itself, the current match, and the location.
497
+ *
498
+ * Safe to call during a server render, where a detached context yields a no-op stub
499
+ * rather than throwing, since an SSR pass has nowhere to navigate to. On the client a
500
+ * missing router is genuine misuse and throws, because the alternative is a component
501
+ * that silently never navigates.
502
+ */
503
+ export function useRouter() {
504
+ const ctx = useContext(RouterContext);
505
+ if (ctx)
506
+ return ctx;
507
+ // On the server, degrade to a no-op stub (see above). On the client, a missing
508
+ // router is genuine misuse: throw.
509
+ if (typeof window === 'undefined')
510
+ return _ssrRouterStub;
511
+ throw new Error('[router] useRouter must be used within <Router>');
512
+ }
513
+ /**
514
+ * Reactive merged params across all matched layers, as an accessor. Deep-equal
515
+ * memoized so it only notifies when a value actually changes (the underlying
516
+ * params object is rebuilt on every transition).
517
+ */
518
+ export function useParams() {
519
+ const params = useRouter().params;
520
+ return createMemo(() => params(), {}, {
521
+ equals: (a, b) => JSON.stringify(a) === JSON.stringify(b),
522
+ });
523
+ }
524
+ /** Params introduced at the nearest matched layer. */
525
+ export function useLevelParams() {
526
+ return useContext(LevelParamsContext) ?? {};
527
+ }
528
+ /**
529
+ * Reactive accessor for a single route param. Default `===` equality on the
530
+ * string value, so it only re-triggers when this key changes, not when other
531
+ * params at the same level change.
532
+ */
533
+ export function useParam(key) {
534
+ const params = useParams();
535
+ return createMemo(() => params()[key]);
536
+ }
537
+ /**
538
+ * Derive a stable value from route params. Only re-triggers when the selector's
539
+ * return value changes. For object returns, pass a custom `equals`.
540
+ *
541
+ * @example
542
+ * const ids = useParamSelector((p) => ({ wsId: p.workspaceId, chId: p.channelId }), deepEqual);
543
+ */
544
+ export function useParamSelector(selector, equals) {
545
+ const params = useParams();
546
+ return createMemo(() => selector(params()), undefined, equals ? { equals } : undefined);
547
+ }
548
+ /**
549
+ * Match a path pattern against the current pathname, reactively. Returns the
550
+ * matched `{ params, path }` or null.
551
+ */
552
+ export function useMatch(path) {
553
+ const location = useLocation();
554
+ return createMemo(() => matchPath(typeof path === 'function' ? path() : path, location.pathname));
555
+ }
556
+ /**
557
+ * Read/write the current query string. Returns `[params, setParams]`: `params`
558
+ * is a reactive accessor of the parsed query; `setParams` merges the given keys
559
+ * (null deletes) and navigates. Matches the old router.
560
+ */
561
+ export function useSearchParams() {
562
+ const location = useLocation();
563
+ const navigate = useNavigate();
564
+ const params = createMemo(() => location.query);
565
+ const setParams = (next, options) => {
566
+ const current = new URLSearchParams(location.search);
567
+ for (const [k, v] of Object.entries(next)) {
568
+ if (v === null)
569
+ current.delete(k);
570
+ else
571
+ current.set(k, v);
572
+ }
573
+ const search = current.toString();
574
+ navigate(location.pathname + (search ? `?${search}` : ''), options);
575
+ };
576
+ return [params, setParams];
577
+ }
578
+ /**
579
+ * Stable accessor from any reactive source with deep-equal memoization. Pass a
580
+ * `key` to select a nested property. Only re-triggers downstream when the value
581
+ * (or selected field) actually changes.
582
+ *
583
+ * @example
584
+ * const ws = select(store.activeWorkspace); // whole object
585
+ * const id = select(store.activeWorkspace, 'id'); // single field
586
+ */
587
+ export function select(source, key) {
588
+ const equals = (a, b) => deepEqual(a, b);
589
+ if (key !== undefined) {
590
+ return createMemo(() => get(source(), key), undefined, { equals });
591
+ }
592
+ return createMemo(source, undefined, { equals });
593
+ }
594
+ /**
595
+ * The current location as a reactive object: reading a property (pathname, etc.)
596
+ * subscribes to location changes. Matches the old router's useLocation().
597
+ */
598
+ export function useLocation() {
599
+ const { location } = useRouter();
600
+ return new Proxy({}, {
601
+ get: (_t, key) => location()[key],
602
+ has: (_t, key) => key in location(),
603
+ ownKeys: () => Reflect.ownKeys(location()),
604
+ getOwnPropertyDescriptor: (_t, key) => Object.getOwnPropertyDescriptor(location(), key),
605
+ });
606
+ }
607
+ /**
608
+ * A delegated click handler that turns plain `<a>` elements into client-side navigations.
609
+ *
610
+ * `<Link>` covers the links an app writes as JSX. This covers the ones it doesn't own:
611
+ * markdown or CMS HTML dropped in through `innerHTML`, where there is no component to
612
+ * bind to and the anchors arrive as prerendered text. Put it on the container and every
613
+ * link inside it navigates without reloading the document:
614
+ *
615
+ * ```tsx
616
+ * const onClick = useLinkNavigation();
617
+ * <article onClick={onClick} innerHTML={doc.html} />
618
+ * ```
619
+ *
620
+ * The anchors stay real anchors: middle-click, right-click -> open in new tab, "copy
621
+ * link address" and crawlers all keep working, because the element is untouched and only
622
+ * a plain left-click is intercepted.
623
+ *
624
+ * Left alone: modified clicks, anything but the primary button, an already-prevented
625
+ * event, `target`, `download`, `rel="external"`, a non-http scheme (`mailto:`, `tel:`),
626
+ * another origin, and a hash pointing inside the current page, that last one is the
627
+ * browser's scroll, and hijacking it breaks in-page anchors.
628
+ */
629
+ export function useLinkNavigation(options = {}) {
630
+ const ctx = useContext(RouterContext);
631
+ return (event) => {
632
+ if (!ctx || event.defaultPrevented || event.button !== 0)
633
+ return;
634
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)
635
+ return;
636
+ const target = event.target;
637
+ const anchor = target?.closest?.('a');
638
+ if (!anchor || !anchor.getAttribute('href'))
639
+ return;
640
+ if (anchor.target && anchor.target !== '_self')
641
+ return;
642
+ if (anchor.hasAttribute('download'))
643
+ return;
644
+ if (/(^|\s)external(\s|$)/.test(anchor.getAttribute('rel') ?? ''))
645
+ return;
646
+ // `anchor.href` is resolved against the document, so a relative `./guide` from
647
+ // prerendered markdown lands on the right absolute path.
648
+ const url = new URL(anchor.href);
649
+ if (url.protocol !== 'http:' && url.protocol !== 'https:')
650
+ return;
651
+ if (url.origin !== window.location.origin)
652
+ return;
653
+ if (url.hash && url.pathname === window.location.pathname && url.search === window.location.search)
654
+ return;
655
+ if (options.shouldNavigate && !options.shouldNavigate(url, anchor))
656
+ return;
657
+ event.preventDefault();
658
+ pendingTransitionMode = options.transition;
659
+ ctx.router.navigate(url.pathname + url.search + url.hash, { replace: options.replace });
660
+ };
661
+ }
662
+ /**
663
+ * A function that navigates, for use outside a `Link`.
664
+ *
665
+ * Reach for it after an action rather than for a plain link: a form that redirects on
666
+ * success, a guard that bounces an unauthenticated visitor. Prefer `Link` for anything a
667
+ * visitor clicks, since an anchor is what middle-click, copy-link and a crawler expect.
668
+ *
669
+ * Navigation is a no-op during a server render, for the reason `useRouter` explains.
670
+ *
671
+ * @example
672
+ * ```ts
673
+ * const navigate = useNavigate();
674
+ * await save(form);
675
+ * navigate(`/posts/${id}`, { replace: true });
676
+ * ```
677
+ */
678
+ export function useNavigate() {
679
+ // useRouter is SSR-safe (returns a no-op stub on the server when the context is
680
+ // detached), so navigation degrades to a no-op during SSR and only throws on
681
+ // genuine client-side misuse outside <Router>.
682
+ const { router } = useRouter();
683
+ return (to, options) => {
684
+ // Handed to the Router's subscriber, which runs synchronously inside the call
685
+ // below and clears it. `transition: 'none'` on one navigation leaves the router's
686
+ // own setting alone for the next.
687
+ pendingTransitionMode = options?.transition;
688
+ router.navigate(to, options);
689
+ };
690
+ }
691
+ /**
692
+ * The resolved data for the nearest route that declared a `data` loader. Always
693
+ * returns a callable accessor (matching the old router): it suspends under
694
+ * <Suspense> while loading and yields undefined when no ancestor declared data.
695
+ */
696
+ export function useRouteData() {
697
+ const acc = useContext(RouteDataContext);
698
+ return acc ?? (() => undefined);
699
+ }
700
+ const read = (v) => typeof v === 'function' ? v() : v;
701
+ // Props Link handles itself, so they aren't blindly forwarded as <a> attributes.
702
+ const LINK_OWN_PROPS = new Set([
703
+ 'href',
704
+ 'slot',
705
+ 'class',
706
+ 'className',
707
+ 'active',
708
+ 'activeClass',
709
+ 'replace',
710
+ 'children',
711
+ ]);
712
+ /**
713
+ * An anchor that navigates without a full page load.
714
+ *
715
+ * Renders a real `<a href>`, so middle-click, copy-link, and a crawler all behave. A
716
+ * plain left click is intercepted and routed instead.
717
+ *
718
+ * Active state defaults to the current pathname matching `href`, and `activeClass` is
719
+ * added while it holds. Override `active` when a section link should stay lit for its
720
+ * children.
721
+ *
722
+ * @example
723
+ * ```ts
724
+ * html`<${Link} href="/about" activeClass="current">About</${Link}>`;
725
+ * ```
726
+ */
727
+ export function Link(props) {
728
+ const ctx = useContext(RouterContext);
729
+ const navigate = () => ctx.router.navigate(read(props.href) ?? '', { replace: read(props.replace) });
730
+ // createNativeElement branches on isServer() -> a real ServerNode <a> (serialized as a true
731
+ // element, not an escaped string) on the server, a real DOM <a> on the client.
732
+ const a = createNativeElement('a');
733
+ setAttribute(a, 'href', read(props.href) ?? '');
734
+ if (props.slot)
735
+ setAttribute(a, 'slot', read(props.slot));
736
+ // Forward any other author attributes (data-*, aria-*, style, id, title, role, ...)
737
+ // onto the <a>, the way <ActionForm> does. Without this a component composed via
738
+ // `as={Link}` (polymorphic) silently loses its styling hooks: e.g. @fluixi-ui
739
+ // parts carry `data-*` slots + inline styles that would otherwise be dropped, so
740
+ // the element renders unstyled. Own-props handled elsewhere are skipped:
741
+ // href/slot above, class/className/active/activeClass in the effect, and
742
+ // children/replace by navigation.
743
+ // Keys, not entries: `Object.entries` reads every prop, and `children` is a getter
744
+ // that builds fresh nodes per access. Under hydration that discarded first build
745
+ // consumes the server's nodes, so the real read below produced a second copy of
746
+ // them, a duplicated logo and pager on every prerendered page.
747
+ for (const k of Object.keys(props)) {
748
+ if (LINK_OWN_PROPS.has(k))
749
+ continue;
750
+ const v = props[k];
751
+ if (typeof v === 'function') {
752
+ // An accessor (`title={() => t('…')}`, `aria-current={() => …}`): followed on the client,
753
+ // read once for the server's markup. Event handlers are not attributes.
754
+ if (/^on[A-Z]/.test(k))
755
+ continue;
756
+ const read = v;
757
+ const apply = () => {
758
+ const value = read();
759
+ if (value == null || value === false)
760
+ a.removeAttribute?.(k);
761
+ else
762
+ setAttribute(a, k, String(value));
763
+ };
764
+ if (typeof a.addEventListener === 'function')
765
+ createEffect(apply);
766
+ else
767
+ apply();
768
+ continue;
769
+ }
770
+ if (v != null)
771
+ setAttribute(a, k, String(v));
772
+ }
773
+ // Client only (ServerNode has no addEventListener). Direct listener (not delegated) so
774
+ // preventDefault reliably blocks navigation.
775
+ if (typeof a.addEventListener === 'function') {
776
+ a.addEventListener('click', (e) => {
777
+ if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey)
778
+ return;
779
+ // No router above this link: let the browser follow it rather than cancel the click
780
+ // and go nowhere.
781
+ if (!ctx)
782
+ return;
783
+ e.preventDefault();
784
+ navigate();
785
+ });
786
+ }
787
+ else {
788
+ // Server: set the static class so the markup matches the client's first render.
789
+ const base = (read(props.className ?? props.class) ?? '');
790
+ if (base)
791
+ setAttribute(a, 'class', base);
792
+ }
793
+ // Client only: keep href + active class in sync with reactive props / location.
794
+ // (Skipped on the server: `a` is a ServerNode without these DOM setters; the static
795
+ // href/class were set above.)
796
+ if (typeof a.addEventListener === 'function') {
797
+ createEffect(() => {
798
+ const href = read(props.href) ?? '';
799
+ a.setAttribute('href', href);
800
+ const base = read(props.className ?? props.class) ?? '';
801
+ const activeClass = read(props.activeClass) ?? '';
802
+ const active = read(props.active) ?? ctx?.location().pathname === href;
803
+ a.className = [base, active ? activeClass : ''].filter(Boolean).join(' ');
804
+ });
805
+ }
806
+ const linkChildren = props.children; // read once (getter builds fresh nodes per access)
807
+ if (linkChildren != null)
808
+ insert(a, linkChildren);
809
+ return a;
810
+ }
811
+ /** Alias for {@link Link}. */
812
+ export const A = Link;
813
+ import { onRevalidate } from './data.js';
814
+ /**
815
+ * A `<form>` wired to a router action: on submit it builds `FormData` from the form and calls
816
+ * `action(formData)` (which tracks submission state via `useSubmission`), without a full
817
+ * page navigation. The markup carries `method="post"` + `action={action.url}` so it's
818
+ * progressive-enhancement-ready.
819
+ *
820
+ * <ActionForm action={save}>
821
+ * <input name="title" />
822
+ * <button>Save</button>
823
+ * </ActionForm>
824
+ *
825
+ * It sends what the fields hold and checks nothing. For a form whose fields are validated
826
+ * first, use `@fluixi-ui/form`'s `Form` with an `action`.
827
+ */
828
+ export function ActionForm(props) {
829
+ const action = props.action;
830
+ const cls = (props.className ?? props.class); // compiler passes class -> className
831
+ const url = action.url ?? '';
832
+ // createNativeElement/setAttribute branch on isServer(), so this builds a real ServerNode
833
+ // <form> on the server (serialized as a true element, not an escaped string) and a real
834
+ // DOM <form> on the client: hydration reuses the same element.
835
+ const form = createNativeElement('form');
836
+ setAttribute(form, 'method', 'post');
837
+ if (url)
838
+ setAttribute(form, 'action', url);
839
+ if (cls)
840
+ setAttribute(form, 'class', cls);
841
+ // Keys, not entries, same reason as Link: reading `children` here would build its
842
+ // nodes once and throw them away.
843
+ for (const k of Object.keys(props)) {
844
+ if (k === 'action' || k === 'children' || k === 'class' || k === 'className')
845
+ continue;
846
+ const v = props[k];
847
+ if (v != null && typeof v !== 'function')
848
+ setAttribute(form, k, String(v));
849
+ }
850
+ // Client only (ServerNode has no addEventListener). Direct listener so preventDefault
851
+ // reliably blocks the native submit/navigation. Fields are passed as a plain object so
852
+ // a `"use server"` action (JSON RPC) gets them unchanged. (File uploads are a follow-up.)
853
+ if (typeof form.addEventListener === 'function') {
854
+ form.addEventListener('submit', (e) => {
855
+ e.preventDefault();
856
+ const data = {};
857
+ new FormData(form).forEach((v, k) => {
858
+ data[k] = v;
859
+ });
860
+ Promise.resolve(action(data)).catch(() => {
861
+ /* error is captured in the submission state via useSubmission */
862
+ });
863
+ });
864
+ }
865
+ // Read children ONCE: the compiler emits `get children()` as a getter that builds fresh
866
+ // nodes on every access, so reading it twice (here + the null check) created a duplicate
867
+ // set: under hydration that meant two <input>s in the form.
868
+ const children = props.children;
869
+ if (children != null)
870
+ insert(form, children);
871
+ return form;
872
+ }
873
+ // ─── Hydration preload ───────────────────────────────────────────────────────────
874
+ /**
875
+ * Download the lazy component chunks for the route(s) matching `pathname` and resolve when
876
+ * they're all loaded. Await this before hydrating: once each matched lazy file-route is
877
+ * resolved it renders synchronously on the first pass (no Suspense fallback, no late
878
+ * re-render), so hydration adopts the server DOM in place instead of building a duplicate
879
+ * subtree next to it. No-op for routes with no lazy components. Safe to call when the URL
880
+ * matches nothing (resolves immediately).
881
+ */
882
+ export async function preloadRoutes(routes, pathname) {
883
+ const match = matchRoutes(routes, pathname);
884
+ if (!match)
885
+ return;
886
+ const loads = [];
887
+ for (const m of match.matched) {
888
+ const c = m.component;
889
+ if (c && typeof c.preload === 'function')
890
+ loads.push(c.preload());
891
+ }
892
+ await Promise.all(loads);
893
+ }
894
+ // ─── Re-exports ────────────────────────────────────────────────────────────────
895
+ export { createMemoryHistory, createBrowserHistory, matchRoutes, matchPath, } from '@fluixi/router';
896
+ // Action/cache/lazy layer is orthogonal to routing: re-export so a route file can
897
+ // swap its import path wholesale. (Routing hooks above are the router ones.)
898
+ export { cache, action, revalidate, createAsync, useSubmission, useSubmissions, useAction, } from './data.js';
899
+ export { lazy } from '../render/lazy.js';
900
+ // Navigation presentation. The router owns the integration; these are exported for
901
+ // apps that need to ask what the environment supports.
902
+ export { supportsViewTransitions, prefersReducedMotion, } from './transition.js';
903
+ // Carried over from the barrel this replaced; route codegen imports it from here.
904
+ export { runWithOwner } from '@fluixi/reactive/signal';