@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.
- package/LICENSE +21 -0
- package/README.md +334 -0
- package/dist/cdn/core.global.js +2 -0
- package/dist/cli/generate.cjs +5639 -0
- package/dist/cli/generate.d.ts +7 -0
- package/dist/cli/generate.d.ts.map +1 -0
- package/dist/cli/generate.js +2580 -0
- package/dist/cli/generate.mjs +5644 -0
- package/dist/cli/index.cjs +5639 -0
- package/dist/cli/index.d.ts +7 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +2580 -0
- package/dist/cli/index.mjs +5644 -0
- package/dist/cli/run.cjs +64 -0
- package/dist/cli/run.d.ts +3 -0
- package/dist/cli/run.d.ts.map +1 -0
- package/dist/cli/run.js +62 -0
- package/dist/cli/run.mjs +40 -0
- package/dist/fluixi.d.ts +5 -0
- package/dist/src/cdn/chunk-RKBFEFZP.mjs +987 -0
- package/dist/src/cdn/core.cjs +1592 -0
- package/dist/src/cdn/core.d.ts +16 -0
- package/dist/src/cdn/core.d.ts.map +1 -0
- package/dist/src/cdn/core.js +15 -0
- package/dist/src/cdn/core.mjs +714 -0
- package/dist/src/cdn/router-V2THK27H.mjs +74 -0
- package/dist/src/index.cjs +1711 -0
- package/dist/src/index.d.ts +4 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +11 -0
- package/dist/src/index.mjs +1833 -0
- package/dist/src/jsx-dev-runtime.cjs +26 -0
- package/dist/src/jsx-dev-runtime.d.ts +2 -0
- package/dist/src/jsx-dev-runtime.d.ts.map +1 -0
- package/dist/src/jsx-dev-runtime.js +1 -0
- package/dist/src/jsx-dev-runtime.mjs +24 -0
- package/dist/src/jsx-runtime.cjs +26 -0
- package/dist/src/jsx-runtime.d.ts +7 -0
- package/dist/src/jsx-runtime.d.ts.map +1 -0
- package/dist/src/jsx-runtime.js +8 -0
- package/dist/src/jsx-runtime.mjs +24 -0
- package/dist/src/lib/client/chunk-FDOPDB6Z.mjs +53 -0
- package/dist/src/lib/client/index.cjs +1210 -0
- package/dist/src/lib/client/index.d.ts +51 -0
- package/dist/src/lib/client/index.d.ts.map +1 -0
- package/dist/src/lib/client/index.js +174 -0
- package/dist/src/lib/client/index.mjs +293 -0
- package/dist/src/lib/client/router-4PTBPZLQ.mjs +891 -0
- package/dist/src/lib/context/context.cjs +81 -0
- package/dist/src/lib/context/context.d.ts +73 -0
- package/dist/src/lib/context/context.d.ts.map +1 -0
- package/dist/src/lib/context/context.js +93 -0
- package/dist/src/lib/context/context.mjs +64 -0
- package/dist/src/lib/context/index.cjs +83 -0
- package/dist/src/lib/context/index.d.ts +3 -0
- package/dist/src/lib/context/index.d.ts.map +1 -0
- package/dist/src/lib/context/index.js +1 -0
- package/dist/src/lib/context/index.mjs +64 -0
- package/dist/src/lib/control-flow.cjs +35 -0
- package/dist/src/lib/control-flow.d.ts +4 -0
- package/dist/src/lib/control-flow.d.ts.map +1 -0
- package/dist/src/lib/control-flow.js +1 -0
- package/dist/src/lib/control-flow.mjs +25 -0
- package/dist/src/lib/core.cjs +1710 -0
- package/dist/src/lib/core.d.ts +91 -0
- package/dist/src/lib/core.d.ts.map +1 -0
- package/dist/src/lib/core.js +158 -0
- package/dist/src/lib/core.mjs +1833 -0
- package/dist/src/lib/env/index.cjs +44 -0
- package/dist/src/lib/env/index.d.ts +26 -0
- package/dist/src/lib/env/index.d.ts.map +1 -0
- package/dist/src/lib/env/index.js +31 -0
- package/dist/src/lib/env/index.mjs +23 -0
- package/dist/src/lib/i18n/index.cjs +123 -0
- package/dist/src/lib/i18n/index.d.ts +110 -0
- package/dist/src/lib/i18n/index.d.ts.map +1 -0
- package/dist/src/lib/i18n/index.js +164 -0
- package/dist/src/lib/i18n/index.mjs +103 -0
- package/dist/src/lib/index.cjs +598 -0
- package/dist/src/lib/index.d.ts +81 -0
- package/dist/src/lib/index.d.ts.map +1 -0
- package/dist/src/lib/index.js +130 -0
- package/dist/src/lib/index.mjs +688 -0
- package/dist/src/lib/isomorphic.cjs +90 -0
- package/dist/src/lib/isomorphic.d.ts +114 -0
- package/dist/src/lib/isomorphic.d.ts.map +1 -0
- package/dist/src/lib/isomorphic.js +132 -0
- package/dist/src/lib/isomorphic.mjs +69 -0
- package/dist/src/lib/jsx-runtime/index.cjs +21 -0
- package/dist/src/lib/jsx-runtime/index.d.ts +13 -0
- package/dist/src/lib/jsx-runtime/index.d.ts.map +1 -0
- package/dist/src/lib/jsx-runtime/index.js +13 -0
- package/dist/src/lib/jsx-runtime/index.mjs +4 -0
- package/dist/src/lib/jsx-runtime/jsx-dev-runtime.cjs +21 -0
- package/dist/src/lib/jsx-runtime/jsx-dev-runtime.d.ts +2 -0
- package/dist/src/lib/jsx-runtime/jsx-dev-runtime.d.ts.map +1 -0
- package/dist/src/lib/jsx-runtime/jsx-dev-runtime.js +1 -0
- package/dist/src/lib/jsx-runtime/jsx-dev-runtime.mjs +4 -0
- package/dist/src/lib/plugins/fluixi-routes-plugin.cjs +302 -0
- package/dist/src/lib/plugins/fluixi-routes-plugin.d.ts +35 -0
- package/dist/src/lib/plugins/fluixi-routes-plugin.d.ts.map +1 -0
- package/dist/src/lib/plugins/fluixi-routes-plugin.js +118 -0
- package/dist/src/lib/plugins/fluixi-routes-plugin.mjs +271 -0
- package/dist/src/lib/plugins/index.cjs +311 -0
- package/dist/src/lib/plugins/index.d.ts +6 -0
- package/dist/src/lib/plugins/index.d.ts.map +1 -0
- package/dist/src/lib/plugins/index.js +7 -0
- package/dist/src/lib/plugins/index.mjs +279 -0
- package/dist/src/lib/plugins/route-codegen.cjs +73 -0
- package/dist/src/lib/plugins/route-codegen.d.ts +12 -0
- package/dist/src/lib/plugins/route-codegen.d.ts.map +1 -0
- package/dist/src/lib/plugins/route-codegen.js +80 -0
- package/dist/src/lib/plugins/route-codegen.mjs +52 -0
- package/dist/src/lib/plugins/route-scanner.cjs +189 -0
- package/dist/src/lib/plugins/route-scanner.d.ts +38 -0
- package/dist/src/lib/plugins/route-scanner.d.ts.map +1 -0
- package/dist/src/lib/plugins/route-scanner.js +215 -0
- package/dist/src/lib/plugins/route-scanner.mjs +158 -0
- package/dist/src/lib/plugins/vite.cjs +27 -0
- package/dist/src/lib/plugins/vite.d.ts +3 -0
- package/dist/src/lib/plugins/vite.d.ts.map +1 -0
- package/dist/src/lib/plugins/vite.js +1 -0
- package/dist/src/lib/plugins/vite.mjs +6 -0
- package/dist/src/lib/render/await.cjs +38 -0
- package/dist/src/lib/render/await.d.ts +26 -0
- package/dist/src/lib/render/await.d.ts.map +1 -0
- package/dist/src/lib/render/await.js +45 -0
- package/dist/src/lib/render/await.mjs +17 -0
- package/dist/src/lib/render/component.cjs +221 -0
- package/dist/src/lib/render/component.d.ts +296 -0
- package/dist/src/lib/render/component.d.ts.map +1 -0
- package/dist/src/lib/render/component.js +514 -0
- package/dist/src/lib/render/component.mjs +214 -0
- package/dist/src/lib/render/deferred.cjs +175 -0
- package/dist/src/lib/render/deferred.d.ts +36 -0
- package/dist/src/lib/render/deferred.d.ts.map +1 -0
- package/dist/src/lib/render/deferred.js +141 -0
- package/dist/src/lib/render/deferred.mjs +158 -0
- package/dist/src/lib/render/index.cjs +573 -0
- package/dist/src/lib/render/index.d.ts +98 -0
- package/dist/src/lib/render/index.d.ts.map +1 -0
- package/dist/src/lib/render/index.js +180 -0
- package/dist/src/lib/render/index.mjs +590 -0
- package/dist/src/lib/render/lazy.cjs +67 -0
- package/dist/src/lib/render/lazy.d.ts +49 -0
- package/dist/src/lib/render/lazy.d.ts.map +1 -0
- package/dist/src/lib/render/lazy.js +105 -0
- package/dist/src/lib/render/lazy.mjs +50 -0
- package/dist/src/lib/render/suspense.cjs +141 -0
- package/dist/src/lib/render/suspense.d.ts +65 -0
- package/dist/src/lib/render/suspense.d.ts.map +1 -0
- package/dist/src/lib/render/suspense.js +196 -0
- package/dist/src/lib/render/suspense.mjs +136 -0
- package/dist/src/lib/render/versions.cjs +35 -0
- package/dist/src/lib/render/versions.d.ts +2 -0
- package/dist/src/lib/render/versions.d.ts.map +1 -0
- package/dist/src/lib/render/versions.js +21 -0
- package/dist/src/lib/render/versions.mjs +14 -0
- package/dist/src/lib/router/data.cjs +228 -0
- package/dist/src/lib/router/data.d.ts +142 -0
- package/dist/src/lib/router/data.d.ts.map +1 -0
- package/dist/src/lib/router/data.js +258 -0
- package/dist/src/lib/router/data.mjs +214 -0
- package/dist/src/lib/router/index.cjs +921 -0
- package/dist/src/lib/router/index.d.ts +274 -0
- package/dist/src/lib/router/index.d.ts.map +1 -0
- package/dist/src/lib/router/index.js +904 -0
- package/dist/src/lib/router/index.mjs +938 -0
- package/dist/src/lib/router/scroll.cjs +79 -0
- package/dist/src/lib/router/scroll.d.ts +19 -0
- package/dist/src/lib/router/scroll.d.ts.map +1 -0
- package/dist/src/lib/router/scroll.js +68 -0
- package/dist/src/lib/router/scroll.mjs +58 -0
- package/dist/src/lib/router/transition.cjs +75 -0
- package/dist/src/lib/router/transition.d.ts +81 -0
- package/dist/src/lib/router/transition.d.ts.map +1 -0
- package/dist/src/lib/router/transition.js +136 -0
- package/dist/src/lib/router/transition.mjs +54 -0
- package/dist/src/lib/server/hydration.cjs +169 -0
- package/dist/src/lib/server/hydration.d.ts +198 -0
- package/dist/src/lib/server/hydration.d.ts.map +1 -0
- package/dist/src/lib/server/hydration.js +345 -0
- package/dist/src/lib/server/hydration.mjs +148 -0
- package/dist/src/lib/server/index.cjs +406 -0
- package/dist/src/lib/server/index.d.ts +115 -0
- package/dist/src/lib/server/index.d.ts.map +1 -0
- package/dist/src/lib/server/index.js +164 -0
- package/dist/src/lib/server/index.mjs +384 -0
- package/dist/src/lib/server/reactive.cjs +197 -0
- package/dist/src/lib/server/reactive.d.ts +239 -0
- package/dist/src/lib/server/reactive.d.ts.map +1 -0
- package/dist/src/lib/server/reactive.js +413 -0
- package/dist/src/lib/server/reactive.mjs +176 -0
- package/dist/src/lib/storage/cache.cjs +51 -0
- package/dist/src/lib/storage/cache.d.ts +18 -0
- package/dist/src/lib/storage/cache.d.ts.map +1 -0
- package/dist/src/lib/storage/cache.js +42 -0
- package/dist/src/lib/storage/cache.mjs +30 -0
- package/dist/src/lib/storage/index.cjs +246 -0
- package/dist/src/lib/storage/index.d.ts +83 -0
- package/dist/src/lib/storage/index.d.ts.map +1 -0
- package/dist/src/lib/storage/index.js +179 -0
- package/dist/src/lib/storage/index.mjs +231 -0
- package/dist/src/lib/storage/persisted.cjs +91 -0
- package/dist/src/lib/storage/persisted.d.ts +53 -0
- package/dist/src/lib/storage/persisted.d.ts.map +1 -0
- package/dist/src/lib/storage/persisted.js +75 -0
- package/dist/src/lib/storage/persisted.mjs +70 -0
- package/dist/src/lib/theme/color-scheme.cjs +281 -0
- package/dist/src/lib/theme/color-scheme.d.ts +61 -0
- package/dist/src/lib/theme/color-scheme.d.ts.map +1 -0
- package/dist/src/lib/theme/color-scheme.js +120 -0
- package/dist/src/lib/theme/color-scheme.mjs +265 -0
- package/dist/src/lib/theme/index.cjs +267 -0
- package/dist/src/lib/theme/index.d.ts +78 -0
- package/dist/src/lib/theme/index.d.ts.map +1 -0
- package/dist/src/lib/theme/index.js +135 -0
- package/dist/src/lib/theme/index.mjs +251 -0
- package/dist/src/lib/theme/provider.cjs +346 -0
- package/dist/src/lib/theme/provider.d.ts +34 -0
- package/dist/src/lib/theme/provider.d.ts.map +1 -0
- package/dist/src/lib/theme/provider.js +60 -0
- package/dist/src/lib/theme/provider.mjs +332 -0
- package/dist/src/lib/utils/index.cjs +209 -0
- package/dist/src/lib/utils/index.d.ts +286 -0
- package/dist/src/lib/utils/index.d.ts.map +1 -0
- package/dist/src/lib/utils/index.js +434 -0
- package/dist/src/lib/utils/index.mjs +188 -0
- package/dist/src/routes.cjs +37 -0
- package/dist/src/routes.d.ts +22 -0
- package/dist/src/routes.d.ts.map +1 -0
- package/dist/src/routes.js +20 -0
- package/dist/src/routes.mjs +17 -0
- package/dist/src/server.cjs +405 -0
- package/dist/src/server.d.ts +63 -0
- package/dist/src/server.d.ts.map +1 -0
- package/dist/src/server.js +62 -0
- package/dist/src/server.mjs +382 -0
- package/dist/src/utils.cjs +21 -0
- package/dist/src/utils.d.ts +10 -0
- package/dist/src/utils.d.ts.map +1 -0
- package/dist/src/utils.js +9 -0
- package/dist/src/utils.mjs +4 -0
- package/dist/src/version.generated.cjs +26 -0
- package/dist/src/version.generated.d.ts +3 -0
- package/dist/src/version.generated.d.ts.map +1 -0
- package/dist/src/version.generated.js +3 -0
- package/dist/src/version.generated.mjs +5 -0
- package/dist/tsconfig.lib.tsbuildinfo +1 -0
- package/package.json +164 -0
- package/src/index.ts +19 -0
- package/types/fluixi.d.ts +5 -0
- 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';
|