@timber-js/app 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/timber.mjs +5 -0
- package/dist/_chunks/error-boundary-dj-WO5uq.js +121 -0
- package/dist/_chunks/error-boundary-dj-WO5uq.js.map +1 -0
- package/dist/_chunks/format-DNt20Kt8.js +163 -0
- package/dist/_chunks/format-DNt20Kt8.js.map +1 -0
- package/dist/_chunks/interception-DIaZN1bF.js +669 -0
- package/dist/_chunks/interception-DIaZN1bF.js.map +1 -0
- package/dist/_chunks/metadata-routes-BDnswgRO.js +141 -0
- package/dist/_chunks/metadata-routes-BDnswgRO.js.map +1 -0
- package/dist/_chunks/registry-DUIpYD_x.js +20 -0
- package/dist/_chunks/registry-DUIpYD_x.js.map +1 -0
- package/dist/_chunks/request-context-D6XHINkR.js +330 -0
- package/dist/_chunks/request-context-D6XHINkR.js.map +1 -0
- package/dist/_chunks/tracing-BtOwb8O6.js +174 -0
- package/dist/_chunks/tracing-BtOwb8O6.js.map +1 -0
- package/dist/_chunks/use-cookie-8ZlA0rr3.js +125 -0
- package/dist/_chunks/use-cookie-8ZlA0rr3.js.map +1 -0
- package/dist/adapters/cloudflare.d.ts +92 -0
- package/dist/adapters/cloudflare.d.ts.map +1 -0
- package/dist/adapters/cloudflare.js +188 -0
- package/dist/adapters/cloudflare.js.map +1 -0
- package/dist/adapters/nitro.d.ts +72 -0
- package/dist/adapters/nitro.d.ts.map +1 -0
- package/dist/adapters/nitro.js +217 -0
- package/dist/adapters/nitro.js.map +1 -0
- package/dist/adapters/types.d.ts +53 -0
- package/dist/adapters/types.d.ts.map +1 -0
- package/dist/cache/index.d.ts +52 -0
- package/dist/cache/index.d.ts.map +1 -0
- package/dist/cache/index.js +283 -0
- package/dist/cache/index.js.map +1 -0
- package/dist/cache/redis-handler.d.ts +45 -0
- package/dist/cache/redis-handler.d.ts.map +1 -0
- package/dist/cache/register-cached-function.d.ts +17 -0
- package/dist/cache/register-cached-function.d.ts.map +1 -0
- package/dist/cache/singleflight.d.ts +11 -0
- package/dist/cache/singleflight.d.ts.map +1 -0
- package/dist/cache/stable-stringify.d.ts +7 -0
- package/dist/cache/stable-stringify.d.ts.map +1 -0
- package/dist/cache/timber-cache.d.ts +21 -0
- package/dist/cache/timber-cache.d.ts.map +1 -0
- package/dist/cli.d.ts +44 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +135 -0
- package/dist/cli.js.map +1 -0
- package/dist/client/browser-entry.d.ts +22 -0
- package/dist/client/browser-entry.d.ts.map +1 -0
- package/dist/client/error-boundary.d.ts +42 -0
- package/dist/client/error-boundary.d.ts.map +1 -0
- package/dist/client/form.d.ts +115 -0
- package/dist/client/form.d.ts.map +1 -0
- package/dist/client/head.d.ts +16 -0
- package/dist/client/head.d.ts.map +1 -0
- package/dist/client/history.d.ts +29 -0
- package/dist/client/history.d.ts.map +1 -0
- package/dist/client/index.d.ts +32 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.js +1218 -0
- package/dist/client/index.js.map +1 -0
- package/dist/client/link-navigate-interceptor.d.ts +28 -0
- package/dist/client/link-navigate-interceptor.d.ts.map +1 -0
- package/dist/client/link-status-provider.d.ts +11 -0
- package/dist/client/link-status-provider.d.ts.map +1 -0
- package/dist/client/link.d.ts +119 -0
- package/dist/client/link.d.ts.map +1 -0
- package/dist/client/nuqs-adapter.d.ts +11 -0
- package/dist/client/nuqs-adapter.d.ts.map +1 -0
- package/dist/client/router-ref.d.ts +11 -0
- package/dist/client/router-ref.d.ts.map +1 -0
- package/dist/client/router.d.ts +85 -0
- package/dist/client/router.d.ts.map +1 -0
- package/dist/client/segment-cache.d.ts +88 -0
- package/dist/client/segment-cache.d.ts.map +1 -0
- package/dist/client/segment-context.d.ts +32 -0
- package/dist/client/segment-context.d.ts.map +1 -0
- package/dist/client/ssr-data.d.ts +64 -0
- package/dist/client/ssr-data.d.ts.map +1 -0
- package/dist/client/types.d.ts +5 -0
- package/dist/client/types.d.ts.map +1 -0
- package/dist/client/unload-guard.d.ts +18 -0
- package/dist/client/unload-guard.d.ts.map +1 -0
- package/dist/client/use-cookie.d.ts +37 -0
- package/dist/client/use-cookie.d.ts.map +1 -0
- package/dist/client/use-link-status.d.ts +35 -0
- package/dist/client/use-link-status.d.ts.map +1 -0
- package/dist/client/use-navigation-pending.d.ts +26 -0
- package/dist/client/use-navigation-pending.d.ts.map +1 -0
- package/dist/client/use-params.d.ts +50 -0
- package/dist/client/use-params.d.ts.map +1 -0
- package/dist/client/use-pathname.d.ts +20 -0
- package/dist/client/use-pathname.d.ts.map +1 -0
- package/dist/client/use-query-states.d.ts +36 -0
- package/dist/client/use-query-states.d.ts.map +1 -0
- package/dist/client/use-router.d.ts +39 -0
- package/dist/client/use-router.d.ts.map +1 -0
- package/dist/client/use-search-params.d.ts +24 -0
- package/dist/client/use-search-params.d.ts.map +1 -0
- package/dist/client/use-selected-layout-segment.d.ts +68 -0
- package/dist/client/use-selected-layout-segment.d.ts.map +1 -0
- package/dist/content/index.d.ts +11 -0
- package/dist/content/index.d.ts.map +1 -0
- package/dist/content/index.js +2 -0
- package/dist/cookies/define-cookie.d.ts +61 -0
- package/dist/cookies/define-cookie.d.ts.map +1 -0
- package/dist/cookies/index.d.ts +3 -0
- package/dist/cookies/index.d.ts.map +1 -0
- package/dist/cookies/index.js +82 -0
- package/dist/cookies/index.js.map +1 -0
- package/dist/fonts/ast.d.ts +38 -0
- package/dist/fonts/ast.d.ts.map +1 -0
- package/dist/fonts/css.d.ts +43 -0
- package/dist/fonts/css.d.ts.map +1 -0
- package/dist/fonts/fallbacks.d.ts +36 -0
- package/dist/fonts/fallbacks.d.ts.map +1 -0
- package/dist/fonts/google.d.ts +122 -0
- package/dist/fonts/google.d.ts.map +1 -0
- package/dist/fonts/local.d.ts +76 -0
- package/dist/fonts/local.d.ts.map +1 -0
- package/dist/fonts/types.d.ts +85 -0
- package/dist/fonts/types.d.ts.map +1 -0
- package/dist/index.d.ts +150 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +14701 -0
- package/dist/index.js.map +1 -0
- package/dist/plugins/adapter-build.d.ts +18 -0
- package/dist/plugins/adapter-build.d.ts.map +1 -0
- package/dist/plugins/build-manifest.d.ts +79 -0
- package/dist/plugins/build-manifest.d.ts.map +1 -0
- package/dist/plugins/build-report.d.ts +63 -0
- package/dist/plugins/build-report.d.ts.map +1 -0
- package/dist/plugins/cache-transform.d.ts +36 -0
- package/dist/plugins/cache-transform.d.ts.map +1 -0
- package/dist/plugins/chunks.d.ts +45 -0
- package/dist/plugins/chunks.d.ts.map +1 -0
- package/dist/plugins/content.d.ts +19 -0
- package/dist/plugins/content.d.ts.map +1 -0
- package/dist/plugins/dev-error-overlay.d.ts +60 -0
- package/dist/plugins/dev-error-overlay.d.ts.map +1 -0
- package/dist/plugins/dev-logs.d.ts +46 -0
- package/dist/plugins/dev-logs.d.ts.map +1 -0
- package/dist/plugins/dev-server.d.ts +22 -0
- package/dist/plugins/dev-server.d.ts.map +1 -0
- package/dist/plugins/dynamic-transform.d.ts +72 -0
- package/dist/plugins/dynamic-transform.d.ts.map +1 -0
- package/dist/plugins/entries.d.ts +21 -0
- package/dist/plugins/entries.d.ts.map +1 -0
- package/dist/plugins/fonts.d.ts +77 -0
- package/dist/plugins/fonts.d.ts.map +1 -0
- package/dist/plugins/mdx.d.ts +21 -0
- package/dist/plugins/mdx.d.ts.map +1 -0
- package/dist/plugins/react-prod.d.ts +18 -0
- package/dist/plugins/react-prod.d.ts.map +1 -0
- package/dist/plugins/routing.d.ts +13 -0
- package/dist/plugins/routing.d.ts.map +1 -0
- package/dist/plugins/server-action-exports.d.ts +26 -0
- package/dist/plugins/server-action-exports.d.ts.map +1 -0
- package/dist/plugins/server-bundle.d.ts +15 -0
- package/dist/plugins/server-bundle.d.ts.map +1 -0
- package/dist/plugins/shims.d.ts +18 -0
- package/dist/plugins/shims.d.ts.map +1 -0
- package/dist/plugins/static-build.d.ts +55 -0
- package/dist/plugins/static-build.d.ts.map +1 -0
- package/dist/routing/codegen.d.ts +29 -0
- package/dist/routing/codegen.d.ts.map +1 -0
- package/dist/routing/index.d.ts +8 -0
- package/dist/routing/index.d.ts.map +1 -0
- package/dist/routing/index.js +2 -0
- package/dist/routing/interception.d.ts +46 -0
- package/dist/routing/interception.d.ts.map +1 -0
- package/dist/routing/scanner.d.ts +28 -0
- package/dist/routing/scanner.d.ts.map +1 -0
- package/dist/routing/status-file-lint.d.ts +33 -0
- package/dist/routing/status-file-lint.d.ts.map +1 -0
- package/dist/routing/types.d.ts +81 -0
- package/dist/routing/types.d.ts.map +1 -0
- package/dist/search-params/analyze.d.ts +54 -0
- package/dist/search-params/analyze.d.ts.map +1 -0
- package/dist/search-params/codecs.d.ts +53 -0
- package/dist/search-params/codecs.d.ts.map +1 -0
- package/dist/search-params/create.d.ts +106 -0
- package/dist/search-params/create.d.ts.map +1 -0
- package/dist/search-params/index.d.ts +7 -0
- package/dist/search-params/index.d.ts.map +1 -0
- package/dist/search-params/index.js +300 -0
- package/dist/search-params/index.js.map +1 -0
- package/dist/search-params/registry.d.ts +20 -0
- package/dist/search-params/registry.d.ts.map +1 -0
- package/dist/server/access-gate.d.ts +42 -0
- package/dist/server/access-gate.d.ts.map +1 -0
- package/dist/server/action-client.d.ts +190 -0
- package/dist/server/action-client.d.ts.map +1 -0
- package/dist/server/action-handler.d.ts +48 -0
- package/dist/server/action-handler.d.ts.map +1 -0
- package/dist/server/actions.d.ts +108 -0
- package/dist/server/actions.d.ts.map +1 -0
- package/dist/server/asset-headers.d.ts +42 -0
- package/dist/server/asset-headers.d.ts.map +1 -0
- package/dist/server/body-limits.d.ts +30 -0
- package/dist/server/body-limits.d.ts.map +1 -0
- package/dist/server/build-manifest.d.ts +120 -0
- package/dist/server/build-manifest.d.ts.map +1 -0
- package/dist/server/canonicalize.d.ts +30 -0
- package/dist/server/canonicalize.d.ts.map +1 -0
- package/dist/server/client-module-map.d.ts +47 -0
- package/dist/server/client-module-map.d.ts.map +1 -0
- package/dist/server/csrf.d.ts +34 -0
- package/dist/server/csrf.d.ts.map +1 -0
- package/dist/server/deny-renderer.d.ts +49 -0
- package/dist/server/deny-renderer.d.ts.map +1 -0
- package/dist/server/dev-logger.d.ts +44 -0
- package/dist/server/dev-logger.d.ts.map +1 -0
- package/dist/server/dev-span-processor.d.ts +29 -0
- package/dist/server/dev-span-processor.d.ts.map +1 -0
- package/dist/server/dev-warnings.d.ts +129 -0
- package/dist/server/dev-warnings.d.ts.map +1 -0
- package/dist/server/early-hints-sender.d.ts +38 -0
- package/dist/server/early-hints-sender.d.ts.map +1 -0
- package/dist/server/early-hints.d.ts +83 -0
- package/dist/server/early-hints.d.ts.map +1 -0
- package/dist/server/error-boundary-wrapper.d.ts +17 -0
- package/dist/server/error-boundary-wrapper.d.ts.map +1 -0
- package/dist/server/error-formatter.d.ts +17 -0
- package/dist/server/error-formatter.d.ts.map +1 -0
- package/dist/server/flush.d.ts +74 -0
- package/dist/server/flush.d.ts.map +1 -0
- package/dist/server/form-data.d.ts +60 -0
- package/dist/server/form-data.d.ts.map +1 -0
- package/dist/server/form-flash.d.ts +78 -0
- package/dist/server/form-flash.d.ts.map +1 -0
- package/dist/server/html-injectors.d.ts +101 -0
- package/dist/server/html-injectors.d.ts.map +1 -0
- package/dist/server/index.d.ts +54 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +2925 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/instrumentation.d.ts +61 -0
- package/dist/server/instrumentation.d.ts.map +1 -0
- package/dist/server/logger.d.ts +83 -0
- package/dist/server/logger.d.ts.map +1 -0
- package/dist/server/manifest-status-resolver.d.ts +58 -0
- package/dist/server/manifest-status-resolver.d.ts.map +1 -0
- package/dist/server/metadata-render.d.ts +20 -0
- package/dist/server/metadata-render.d.ts.map +1 -0
- package/dist/server/metadata-routes.d.ts +67 -0
- package/dist/server/metadata-routes.d.ts.map +1 -0
- package/dist/server/metadata.d.ts +67 -0
- package/dist/server/metadata.d.ts.map +1 -0
- package/dist/server/middleware-runner.d.ts +21 -0
- package/dist/server/middleware-runner.d.ts.map +1 -0
- package/dist/server/nuqs-ssr-provider.d.ts +28 -0
- package/dist/server/nuqs-ssr-provider.d.ts.map +1 -0
- package/dist/server/pipeline.d.ts +81 -0
- package/dist/server/pipeline.d.ts.map +1 -0
- package/dist/server/prerender.d.ts +77 -0
- package/dist/server/prerender.d.ts.map +1 -0
- package/dist/server/primitives.d.ts +131 -0
- package/dist/server/primitives.d.ts.map +1 -0
- package/dist/server/proxy.d.ts +23 -0
- package/dist/server/proxy.d.ts.map +1 -0
- package/dist/server/request-context.d.ts +175 -0
- package/dist/server/request-context.d.ts.map +1 -0
- package/dist/server/route-element-builder.d.ts +66 -0
- package/dist/server/route-element-builder.d.ts.map +1 -0
- package/dist/server/route-handler.d.ts +35 -0
- package/dist/server/route-handler.d.ts.map +1 -0
- package/dist/server/route-matcher.d.ts +78 -0
- package/dist/server/route-matcher.d.ts.map +1 -0
- package/dist/server/rsc-entry/api-handler.d.ts +11 -0
- package/dist/server/rsc-entry/api-handler.d.ts.map +1 -0
- package/dist/server/rsc-entry/error-renderer.d.ts +30 -0
- package/dist/server/rsc-entry/error-renderer.d.ts.map +1 -0
- package/dist/server/rsc-entry/helpers.d.ts +73 -0
- package/dist/server/rsc-entry/helpers.d.ts.map +1 -0
- package/dist/server/rsc-entry/index.d.ts +11 -0
- package/dist/server/rsc-entry/index.d.ts.map +1 -0
- package/dist/server/rsc-entry/ssr-bridge.d.ts +6 -0
- package/dist/server/rsc-entry/ssr-bridge.d.ts.map +1 -0
- package/dist/server/slot-resolver.d.ts +34 -0
- package/dist/server/slot-resolver.d.ts.map +1 -0
- package/dist/server/ssr-entry.d.ts +73 -0
- package/dist/server/ssr-entry.d.ts.map +1 -0
- package/dist/server/ssr-render.d.ts +67 -0
- package/dist/server/ssr-render.d.ts.map +1 -0
- package/dist/server/status-code-resolver.d.ts +77 -0
- package/dist/server/status-code-resolver.d.ts.map +1 -0
- package/dist/server/tracing.d.ts +99 -0
- package/dist/server/tracing.d.ts.map +1 -0
- package/dist/server/tree-builder.d.ts +116 -0
- package/dist/server/tree-builder.d.ts.map +1 -0
- package/dist/server/types.d.ts +231 -0
- package/dist/server/types.d.ts.map +1 -0
- package/dist/shims/font-google.d.ts +41 -0
- package/dist/shims/font-google.d.ts.map +1 -0
- package/dist/shims/headers.d.ts +11 -0
- package/dist/shims/headers.d.ts.map +1 -0
- package/dist/shims/image.d.ts +328 -0
- package/dist/shims/image.d.ts.map +1 -0
- package/dist/shims/link.d.ts +9 -0
- package/dist/shims/link.d.ts.map +1 -0
- package/dist/shims/navigation-client.d.ts +25 -0
- package/dist/shims/navigation-client.d.ts.map +1 -0
- package/dist/shims/navigation.d.ts +25 -0
- package/dist/shims/navigation.d.ts.map +1 -0
- package/dist/utils/directive-parser.d.ts +70 -0
- package/dist/utils/directive-parser.d.ts.map +1 -0
- package/dist/utils/format.d.ts +6 -0
- package/dist/utils/format.d.ts.map +1 -0
- package/dist/utils/startup-timer.d.ts +34 -0
- package/dist/utils/startup-timer.d.ts.map +1 -0
- package/package.json +140 -0
|
@@ -0,0 +1,1218 @@
|
|
|
1
|
+
import { t as TimberErrorBoundary } from "../_chunks/error-boundary-dj-WO5uq.js";
|
|
2
|
+
import { i as setSsrData, n as clearSsrData, r as getSsrData, t as useCookie } from "../_chunks/use-cookie-8ZlA0rr3.js";
|
|
3
|
+
import { t as getSearchParams$1 } from "../_chunks/registry-DUIpYD_x.js";
|
|
4
|
+
import { createContext, createElement, useActionState as useActionState$1, useContext, useEffect, useMemo, useRef, useSyncExternalStore, useTransition } from "react";
|
|
5
|
+
import { jsxDEV } from "react/jsx-dev-runtime";
|
|
6
|
+
import { useQueryStates as useQueryStates$1 } from "nuqs";
|
|
7
|
+
//#region src/client/link-navigate-interceptor.tsx
|
|
8
|
+
var _jsxFileName$2 = "/Users/dsaewitz/y/timber-js-fresh/packages/timber-app/src/client/link-navigate-interceptor.tsx";
|
|
9
|
+
/** Symbol used to store the onNavigate callback on anchor elements. */
|
|
10
|
+
var ON_NAVIGATE_KEY = "__timberOnNavigate";
|
|
11
|
+
/**
|
|
12
|
+
* Client component rendered inside <Link> that attaches the onNavigate
|
|
13
|
+
* callback to the closest <a> ancestor via a DOM property. The callback
|
|
14
|
+
* is cleaned up on unmount.
|
|
15
|
+
*
|
|
16
|
+
* Renders no extra DOM — just a transparent wrapper.
|
|
17
|
+
*/
|
|
18
|
+
function LinkNavigateInterceptor({ onNavigate, children }) {
|
|
19
|
+
const ref = useRef(null);
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
const anchor = ref.current?.closest("a");
|
|
22
|
+
if (!anchor) return;
|
|
23
|
+
anchor[ON_NAVIGATE_KEY] = onNavigate;
|
|
24
|
+
return () => {
|
|
25
|
+
delete anchor[ON_NAVIGATE_KEY];
|
|
26
|
+
};
|
|
27
|
+
}, [onNavigate]);
|
|
28
|
+
return /* @__PURE__ */ jsxDEV("span", {
|
|
29
|
+
ref,
|
|
30
|
+
style: { display: "contents" },
|
|
31
|
+
children
|
|
32
|
+
}, void 0, false, {
|
|
33
|
+
fileName: _jsxFileName$2,
|
|
34
|
+
lineNumber: 58,
|
|
35
|
+
columnNumber: 5
|
|
36
|
+
}, this);
|
|
37
|
+
}
|
|
38
|
+
//#endregion
|
|
39
|
+
//#region src/client/use-link-status.ts
|
|
40
|
+
/**
|
|
41
|
+
* React context provided by <Link>. Holds the pending status
|
|
42
|
+
* for that specific link's navigation.
|
|
43
|
+
*/
|
|
44
|
+
var LinkStatusContext = createContext({ pending: false });
|
|
45
|
+
/**
|
|
46
|
+
* Returns `{ pending: true }` while the nearest parent `<Link>` component's
|
|
47
|
+
* navigation is in flight. Must be used inside a `<Link>` component's children.
|
|
48
|
+
*
|
|
49
|
+
* Unlike `useNavigationPending()` which is global, this hook is scoped to
|
|
50
|
+
* the nearest parent `<Link>` — only the link the user clicked shows pending.
|
|
51
|
+
*
|
|
52
|
+
* ```tsx
|
|
53
|
+
* 'use client'
|
|
54
|
+
* import { Link, useLinkStatus } from '@timber/app/client'
|
|
55
|
+
*
|
|
56
|
+
* function Hint() {
|
|
57
|
+
* const { pending } = useLinkStatus()
|
|
58
|
+
* return <span className={pending ? 'opacity-50' : ''} />
|
|
59
|
+
* }
|
|
60
|
+
*
|
|
61
|
+
* export function NavLink({ href, children }) {
|
|
62
|
+
* return (
|
|
63
|
+
* <Link href={href}>
|
|
64
|
+
* {children} <Hint />
|
|
65
|
+
* </Link>
|
|
66
|
+
* )
|
|
67
|
+
* }
|
|
68
|
+
* ```
|
|
69
|
+
*/
|
|
70
|
+
function useLinkStatus() {
|
|
71
|
+
return useContext(LinkStatusContext);
|
|
72
|
+
}
|
|
73
|
+
//#endregion
|
|
74
|
+
//#region src/client/router-ref.ts
|
|
75
|
+
var globalRouter = null;
|
|
76
|
+
/**
|
|
77
|
+
* Get the global router instance. Throws if called before bootstrap.
|
|
78
|
+
* Used by client-side hooks (useNavigationPending, etc.)
|
|
79
|
+
*/
|
|
80
|
+
function getRouter() {
|
|
81
|
+
if (!globalRouter) throw new Error("[timber] Router not initialized. getRouter() was called before bootstrap().");
|
|
82
|
+
return globalRouter;
|
|
83
|
+
}
|
|
84
|
+
//#endregion
|
|
85
|
+
//#region src/client/link-status-provider.tsx
|
|
86
|
+
var _jsxFileName$1 = "/Users/dsaewitz/y/timber-js-fresh/packages/timber-app/src/client/link-status-provider.tsx";
|
|
87
|
+
var NOT_PENDING = { pending: false };
|
|
88
|
+
var IS_PENDING = { pending: true };
|
|
89
|
+
/**
|
|
90
|
+
* Client component that subscribes to the router's pending URL and provides
|
|
91
|
+
* a scoped LinkStatusContext to children. Renders no extra DOM — just a
|
|
92
|
+
* context provider around children.
|
|
93
|
+
*/
|
|
94
|
+
function LinkStatusProvider({ href, children }) {
|
|
95
|
+
const status = useSyncExternalStore((callback) => {
|
|
96
|
+
try {
|
|
97
|
+
return getRouter().onPendingChange(callback);
|
|
98
|
+
} catch {
|
|
99
|
+
return () => {};
|
|
100
|
+
}
|
|
101
|
+
}, () => {
|
|
102
|
+
try {
|
|
103
|
+
if (getRouter().getPendingUrl() === href) return IS_PENDING;
|
|
104
|
+
return NOT_PENDING;
|
|
105
|
+
} catch {
|
|
106
|
+
return NOT_PENDING;
|
|
107
|
+
}
|
|
108
|
+
}, () => NOT_PENDING);
|
|
109
|
+
return /* @__PURE__ */ jsxDEV(LinkStatusContext.Provider, {
|
|
110
|
+
value: status,
|
|
111
|
+
children
|
|
112
|
+
}, void 0, false, {
|
|
113
|
+
fileName: _jsxFileName$1,
|
|
114
|
+
lineNumber: 39,
|
|
115
|
+
columnNumber: 10
|
|
116
|
+
}, this);
|
|
117
|
+
}
|
|
118
|
+
//#endregion
|
|
119
|
+
//#region src/client/link.tsx
|
|
120
|
+
var _jsxFileName = "/Users/dsaewitz/y/timber-js-fresh/packages/timber-app/src/client/link.tsx";
|
|
121
|
+
/**
|
|
122
|
+
* Reject dangerous URL schemes that could execute script.
|
|
123
|
+
* Security: design/13-security.md § Link scheme injection (test #9)
|
|
124
|
+
*/
|
|
125
|
+
var DANGEROUS_SCHEMES = /^\s*(javascript|data|vbscript):/i;
|
|
126
|
+
function validateLinkHref(href) {
|
|
127
|
+
if (DANGEROUS_SCHEMES.test(href)) throw new Error(`<Link> received a dangerous href: "${href}". javascript:, data:, and vbscript: URLs are not allowed.`);
|
|
128
|
+
}
|
|
129
|
+
/** Returns true if the href is an internal path (not an external URL) */
|
|
130
|
+
function isInternalHref(href) {
|
|
131
|
+
if (href.startsWith("/") || href.startsWith("#") || href.startsWith("?")) return true;
|
|
132
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(href)) return false;
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Interpolate dynamic segments in a route pattern with actual values.
|
|
137
|
+
* e.g. interpolateParams("/products/[id]", { id: "123" }) → "/products/123"
|
|
138
|
+
*
|
|
139
|
+
* Supports:
|
|
140
|
+
* - [param] → single segment
|
|
141
|
+
* - [...param] → catch-all (joined with /)
|
|
142
|
+
* - [[...param]] → optional catch-all (omitted if undefined/empty)
|
|
143
|
+
*/
|
|
144
|
+
function interpolateParams(pattern, params) {
|
|
145
|
+
return pattern.replace(/\[\[\.\.\.(\w+)\]\]|\[\.\.\.(\w+)\]|\[(\w+)\]/g, (_match, optionalCatchAll, catchAll, single) => {
|
|
146
|
+
if (optionalCatchAll) {
|
|
147
|
+
const value = params[optionalCatchAll];
|
|
148
|
+
if (value === void 0 || Array.isArray(value) && value.length === 0) return "";
|
|
149
|
+
return (Array.isArray(value) ? value : [value]).map(encodeURIComponent).join("/");
|
|
150
|
+
}
|
|
151
|
+
if (catchAll) {
|
|
152
|
+
const value = params[catchAll];
|
|
153
|
+
if (value === void 0) throw new Error(`<Link> missing required catch-all param "${catchAll}" for pattern "${pattern}".`);
|
|
154
|
+
const segments = Array.isArray(value) ? value : [value];
|
|
155
|
+
if (segments.length === 0) throw new Error(`<Link> catch-all param "${catchAll}" must have at least one segment for pattern "${pattern}".`);
|
|
156
|
+
return segments.map(encodeURIComponent).join("/");
|
|
157
|
+
}
|
|
158
|
+
const value = params[single];
|
|
159
|
+
if (value === void 0) throw new Error(`<Link> missing required param "${single}" for pattern "${pattern}".`);
|
|
160
|
+
if (Array.isArray(value)) throw new Error(`<Link> param "${single}" expected a string but received an array for pattern "${pattern}".`);
|
|
161
|
+
return encodeURIComponent(String(value));
|
|
162
|
+
}).replace(/\/+$/, "") || "/";
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Resolve the final href string from Link props.
|
|
166
|
+
*
|
|
167
|
+
* Handles:
|
|
168
|
+
* - params interpolation into route patterns
|
|
169
|
+
* - searchParams serialization via SearchParamsDefinition
|
|
170
|
+
* - Validation that searchParams and inline query strings are exclusive
|
|
171
|
+
*/
|
|
172
|
+
function resolveHref(href, params, searchParams) {
|
|
173
|
+
let resolvedPath = href;
|
|
174
|
+
if (params) resolvedPath = interpolateParams(href, params);
|
|
175
|
+
if (searchParams) {
|
|
176
|
+
if (resolvedPath.includes("?")) throw new Error("<Link> received both a searchParams prop and a query string in href. These are mutually exclusive — use one or the other.");
|
|
177
|
+
const qs = searchParams.definition.serialize(searchParams.values);
|
|
178
|
+
if (qs) resolvedPath = `${resolvedPath}?${qs}`;
|
|
179
|
+
}
|
|
180
|
+
return resolvedPath;
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Build the HTML attributes for a Link. Separated from the component
|
|
184
|
+
* for testability — the component just spreads these onto an <a>.
|
|
185
|
+
*/
|
|
186
|
+
function buildLinkProps(props) {
|
|
187
|
+
const resolvedHref = resolveHref(props.href, props.params, props.searchParams);
|
|
188
|
+
validateLinkHref(resolvedHref);
|
|
189
|
+
const output = { href: resolvedHref };
|
|
190
|
+
if (isInternalHref(resolvedHref)) {
|
|
191
|
+
output["data-timber-link"] = true;
|
|
192
|
+
if (props.prefetch) output["data-timber-prefetch"] = true;
|
|
193
|
+
if (props.scroll === false) output["data-timber-scroll"] = "false";
|
|
194
|
+
}
|
|
195
|
+
return output;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Navigation link with progressive enhancement.
|
|
199
|
+
*
|
|
200
|
+
* Renders as a plain `<a>` tag — works without JavaScript. When the client
|
|
201
|
+
* runtime is active, it intercepts clicks on links marked with
|
|
202
|
+
* `data-timber-link` to perform RSC-based client navigation.
|
|
203
|
+
*
|
|
204
|
+
* Supports typed routes via codegen overloads. At runtime:
|
|
205
|
+
* - `params` prop interpolates dynamic segments in the href pattern
|
|
206
|
+
* - `searchParams` prop serializes query parameters via a SearchParamsDefinition
|
|
207
|
+
*/
|
|
208
|
+
function Link({ href, prefetch, scroll, params, searchParams, onNavigate, children, ...rest }) {
|
|
209
|
+
const linkProps = buildLinkProps({
|
|
210
|
+
href,
|
|
211
|
+
prefetch,
|
|
212
|
+
scroll,
|
|
213
|
+
params,
|
|
214
|
+
searchParams
|
|
215
|
+
});
|
|
216
|
+
const inner = /* @__PURE__ */ jsxDEV(LinkStatusProvider, {
|
|
217
|
+
href: linkProps.href,
|
|
218
|
+
children
|
|
219
|
+
}, void 0, false, {
|
|
220
|
+
fileName: _jsxFileName,
|
|
221
|
+
lineNumber: 299,
|
|
222
|
+
columnNumber: 17
|
|
223
|
+
}, this);
|
|
224
|
+
return /* @__PURE__ */ jsxDEV("a", {
|
|
225
|
+
...rest,
|
|
226
|
+
...linkProps,
|
|
227
|
+
children: onNavigate ? /* @__PURE__ */ jsxDEV(LinkNavigateInterceptor, {
|
|
228
|
+
onNavigate,
|
|
229
|
+
children: inner
|
|
230
|
+
}, void 0, false, {
|
|
231
|
+
fileName: _jsxFileName,
|
|
232
|
+
lineNumber: 304,
|
|
233
|
+
columnNumber: 9
|
|
234
|
+
}, this) : inner
|
|
235
|
+
}, void 0, false, {
|
|
236
|
+
fileName: _jsxFileName,
|
|
237
|
+
lineNumber: 302,
|
|
238
|
+
columnNumber: 5
|
|
239
|
+
}, this);
|
|
240
|
+
}
|
|
241
|
+
//#endregion
|
|
242
|
+
//#region src/client/segment-cache.ts
|
|
243
|
+
/**
|
|
244
|
+
* Maintains the client-side segment tree representing currently mounted
|
|
245
|
+
* layouts and pages. Used for navigation reconciliation — the router diffs
|
|
246
|
+
* new routes against this tree to determine which segments to re-fetch.
|
|
247
|
+
*/
|
|
248
|
+
var SegmentCache = class {
|
|
249
|
+
root;
|
|
250
|
+
get(segment) {
|
|
251
|
+
if (segment === "/" || segment === this.root?.segment) return this.root;
|
|
252
|
+
}
|
|
253
|
+
set(segment, node) {
|
|
254
|
+
if (segment === "/" || !this.root) this.root = node;
|
|
255
|
+
}
|
|
256
|
+
clear() {
|
|
257
|
+
this.root = void 0;
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Serialize the mounted segment tree for the X-Timber-State-Tree header.
|
|
261
|
+
* Only includes sync segments — async segments are excluded because the
|
|
262
|
+
* server must always re-render them (they may depend on request context).
|
|
263
|
+
*
|
|
264
|
+
* This is a performance optimization only, NOT a security boundary.
|
|
265
|
+
* The server always runs all access.ts files regardless of the state tree.
|
|
266
|
+
*/
|
|
267
|
+
serializeStateTree() {
|
|
268
|
+
const segments = [];
|
|
269
|
+
if (this.root) collectSyncSegments(this.root, segments);
|
|
270
|
+
return { segments };
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
/** Recursively collect sync segment paths from the tree */
|
|
274
|
+
function collectSyncSegments(node, out) {
|
|
275
|
+
if (!node.isAsync) out.push(node.segment);
|
|
276
|
+
for (const child of node.children.values()) collectSyncSegments(child, out);
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Build a SegmentNode tree from flat segment metadata.
|
|
280
|
+
*
|
|
281
|
+
* Takes an ordered list of segment descriptors (root → leaf) from the
|
|
282
|
+
* server's X-Timber-Segments header and constructs the hierarchical
|
|
283
|
+
* tree structure that SegmentCache expects.
|
|
284
|
+
*
|
|
285
|
+
* Each segment is nested as a child of the previous one, forming a
|
|
286
|
+
* linear chain from root to leaf. The leaf segment (page) is excluded
|
|
287
|
+
* from the tree — pages are never cached across navigations.
|
|
288
|
+
*/
|
|
289
|
+
function buildSegmentTree(segments) {
|
|
290
|
+
if (segments.length === 0) return void 0;
|
|
291
|
+
const layouts = segments.length > 1 ? segments.slice(0, -1) : segments;
|
|
292
|
+
let root;
|
|
293
|
+
let parent;
|
|
294
|
+
for (const info of layouts) {
|
|
295
|
+
const node = {
|
|
296
|
+
segment: info.path,
|
|
297
|
+
payload: null,
|
|
298
|
+
isAsync: info.isAsync,
|
|
299
|
+
children: /* @__PURE__ */ new Map()
|
|
300
|
+
};
|
|
301
|
+
if (!root) root = node;
|
|
302
|
+
if (parent) parent.children.set(info.path, node);
|
|
303
|
+
parent = node;
|
|
304
|
+
}
|
|
305
|
+
return root;
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* Short-lived cache for hover-triggered prefetches. Entries expire after
|
|
309
|
+
* 30 seconds. When a link is clicked, the prefetched payload is consumed
|
|
310
|
+
* (moved to the history stack) and removed from this cache.
|
|
311
|
+
*
|
|
312
|
+
* timber.js does NOT prefetch on viewport intersection — only explicit
|
|
313
|
+
* hover on <Link prefetch> triggers a prefetch.
|
|
314
|
+
*/
|
|
315
|
+
var PrefetchCache = class PrefetchCache {
|
|
316
|
+
static TTL_MS = 3e4;
|
|
317
|
+
entries = /* @__PURE__ */ new Map();
|
|
318
|
+
set(url, result) {
|
|
319
|
+
this.entries.set(url, {
|
|
320
|
+
result,
|
|
321
|
+
expiresAt: Date.now() + PrefetchCache.TTL_MS
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
get(url) {
|
|
325
|
+
const entry = this.entries.get(url);
|
|
326
|
+
if (!entry) return void 0;
|
|
327
|
+
if (Date.now() >= entry.expiresAt) {
|
|
328
|
+
this.entries.delete(url);
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
return entry.result;
|
|
332
|
+
}
|
|
333
|
+
/** Get and remove the entry (used when navigation consumes a prefetch) */
|
|
334
|
+
consume(url) {
|
|
335
|
+
const result = this.get(url);
|
|
336
|
+
if (result !== void 0) this.entries.delete(url);
|
|
337
|
+
return result;
|
|
338
|
+
}
|
|
339
|
+
};
|
|
340
|
+
//#endregion
|
|
341
|
+
//#region src/client/history.ts
|
|
342
|
+
/**
|
|
343
|
+
* Session-lived history stack keyed by URL. Enables instant back/forward
|
|
344
|
+
* navigation without a server roundtrip.
|
|
345
|
+
*
|
|
346
|
+
* On forward navigation, the new page's payload is pushed onto the stack.
|
|
347
|
+
* On popstate, the cached payload is replayed instantly.
|
|
348
|
+
*
|
|
349
|
+
* Scroll positions are stored in history.state (browser History API),
|
|
350
|
+
* not in this stack — see design/19-client-navigation.md §Scroll Restoration.
|
|
351
|
+
*
|
|
352
|
+
* Entries persist for the session duration (no expiry) and are cleared
|
|
353
|
+
* when the tab is closed — matching browser back-button behavior.
|
|
354
|
+
*/
|
|
355
|
+
var HistoryStack = class {
|
|
356
|
+
entries = /* @__PURE__ */ new Map();
|
|
357
|
+
push(url, entry) {
|
|
358
|
+
this.entries.set(url, entry);
|
|
359
|
+
}
|
|
360
|
+
get(url) {
|
|
361
|
+
return this.entries.get(url);
|
|
362
|
+
}
|
|
363
|
+
has(url) {
|
|
364
|
+
return this.entries.has(url);
|
|
365
|
+
}
|
|
366
|
+
};
|
|
367
|
+
//#endregion
|
|
368
|
+
//#region src/client/use-params.ts
|
|
369
|
+
var currentParams = {};
|
|
370
|
+
/**
|
|
371
|
+
* Set the current route params. Called by the framework internals
|
|
372
|
+
* during navigation — not intended for direct use by app code.
|
|
373
|
+
*
|
|
374
|
+
* On the client, the segment router calls this on each navigation.
|
|
375
|
+
* During SSR, params are also available via getSsrData().params
|
|
376
|
+
* (ALS-backed), but setCurrentParams is still called for the
|
|
377
|
+
* module-level fallback path.
|
|
378
|
+
*/
|
|
379
|
+
function setCurrentParams(params) {
|
|
380
|
+
currentParams = params;
|
|
381
|
+
}
|
|
382
|
+
function useParams(_route) {
|
|
383
|
+
const ssrData = getSsrData();
|
|
384
|
+
if (ssrData) return ssrData.params;
|
|
385
|
+
return currentParams;
|
|
386
|
+
}
|
|
387
|
+
//#endregion
|
|
388
|
+
//#region src/client/router.ts
|
|
389
|
+
/**
|
|
390
|
+
* Thrown when an RSC payload response contains X-Timber-Redirect header.
|
|
391
|
+
* Caught in navigate() to trigger a soft router navigation to the redirect target.
|
|
392
|
+
*/
|
|
393
|
+
var RedirectError = class extends Error {
|
|
394
|
+
redirectUrl;
|
|
395
|
+
constructor(url) {
|
|
396
|
+
super(`Server redirect to ${url}`);
|
|
397
|
+
this.redirectUrl = url;
|
|
398
|
+
}
|
|
399
|
+
};
|
|
400
|
+
/**
|
|
401
|
+
* Check if an error is an abort error (connection closed / fetch aborted).
|
|
402
|
+
* Browsers throw DOMException with name 'AbortError' when a fetch is aborted.
|
|
403
|
+
*/
|
|
404
|
+
function isAbortError(error) {
|
|
405
|
+
if (error instanceof DOMException && error.name === "AbortError") return true;
|
|
406
|
+
if (error instanceof Error && error.name === "AbortError") return true;
|
|
407
|
+
return false;
|
|
408
|
+
}
|
|
409
|
+
var RSC_CONTENT_TYPE = "text/x-component";
|
|
410
|
+
/**
|
|
411
|
+
* Generate a short random cache-busting ID (5 chars, a-z0-9).
|
|
412
|
+
* Matches the format Next.js uses for _rsc params.
|
|
413
|
+
*/
|
|
414
|
+
function generateCacheBustId() {
|
|
415
|
+
const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
416
|
+
let id = "";
|
|
417
|
+
for (let i = 0; i < 5; i++) id += chars[Math.random() * 36 | 0];
|
|
418
|
+
return id;
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* Append a `_rsc=<id>` query parameter to the URL.
|
|
422
|
+
* Follows Next.js's pattern — prevents CDN/browser from serving cached HTML
|
|
423
|
+
* for RSC navigation requests and signals that this is an RSC fetch.
|
|
424
|
+
*/
|
|
425
|
+
function appendRscParam(url) {
|
|
426
|
+
return `${url}${url.includes("?") ? "&" : "?"}_rsc=${generateCacheBustId()}`;
|
|
427
|
+
}
|
|
428
|
+
function buildRscHeaders(stateTree, currentUrl) {
|
|
429
|
+
const headers = { Accept: RSC_CONTENT_TYPE };
|
|
430
|
+
if (stateTree) headers["X-Timber-State-Tree"] = JSON.stringify(stateTree);
|
|
431
|
+
if (currentUrl) headers["X-Timber-URL"] = currentUrl;
|
|
432
|
+
return headers;
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* Extract head elements from the X-Timber-Head response header.
|
|
436
|
+
* Returns null if the header is missing or malformed.
|
|
437
|
+
*/
|
|
438
|
+
function extractHeadElements(response) {
|
|
439
|
+
const header = response.headers.get("X-Timber-Head");
|
|
440
|
+
if (!header) return null;
|
|
441
|
+
try {
|
|
442
|
+
return JSON.parse(decodeURIComponent(header));
|
|
443
|
+
} catch {
|
|
444
|
+
return null;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* Extract segment metadata from the X-Timber-Segments response header.
|
|
449
|
+
* Returns null if the header is missing or malformed.
|
|
450
|
+
*
|
|
451
|
+
* Format: JSON array of {path, isAsync} objects describing the rendered
|
|
452
|
+
* segment chain from root to leaf. Used to populate the client-side
|
|
453
|
+
* segment cache for state tree diffing on subsequent navigations.
|
|
454
|
+
*/
|
|
455
|
+
function extractSegmentInfo(response) {
|
|
456
|
+
const header = response.headers.get("X-Timber-Segments");
|
|
457
|
+
if (!header) return null;
|
|
458
|
+
try {
|
|
459
|
+
return JSON.parse(header);
|
|
460
|
+
} catch {
|
|
461
|
+
return null;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
/**
|
|
465
|
+
* Extract route params from the X-Timber-Params response header.
|
|
466
|
+
* Returns null if the header is missing or malformed.
|
|
467
|
+
*
|
|
468
|
+
* Used to populate useParams() after client-side navigation.
|
|
469
|
+
*/
|
|
470
|
+
function extractParams(response) {
|
|
471
|
+
const header = response.headers.get("X-Timber-Params");
|
|
472
|
+
if (!header) return null;
|
|
473
|
+
try {
|
|
474
|
+
return JSON.parse(header);
|
|
475
|
+
} catch {
|
|
476
|
+
return null;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
/**
|
|
480
|
+
* Fetch an RSC payload from the server. If a decodeRsc function is provided,
|
|
481
|
+
* the response is decoded into a React element tree via createFromFetch.
|
|
482
|
+
* Otherwise, the raw response text is returned (test mode).
|
|
483
|
+
*
|
|
484
|
+
* Also extracts head elements from the X-Timber-Head response header
|
|
485
|
+
* so the client can update document.title and <meta> tags after navigation.
|
|
486
|
+
*/
|
|
487
|
+
async function fetchRscPayload(url, deps, stateTree, currentUrl) {
|
|
488
|
+
const rscUrl = appendRscParam(url);
|
|
489
|
+
const headers = buildRscHeaders(stateTree, currentUrl);
|
|
490
|
+
if (deps.decodeRsc) {
|
|
491
|
+
const fetchPromise = deps.fetch(rscUrl, {
|
|
492
|
+
headers,
|
|
493
|
+
redirect: "manual"
|
|
494
|
+
});
|
|
495
|
+
let headElements = null;
|
|
496
|
+
let segmentInfo = null;
|
|
497
|
+
let params = null;
|
|
498
|
+
const wrappedPromise = fetchPromise.then((response) => {
|
|
499
|
+
const redirectLocation = response.headers.get("X-Timber-Redirect") || (response.status >= 300 && response.status < 400 ? response.headers.get("Location") : null);
|
|
500
|
+
if (redirectLocation) throw new RedirectError(redirectLocation);
|
|
501
|
+
headElements = extractHeadElements(response);
|
|
502
|
+
segmentInfo = extractSegmentInfo(response);
|
|
503
|
+
params = extractParams(response);
|
|
504
|
+
return response;
|
|
505
|
+
});
|
|
506
|
+
await wrappedPromise;
|
|
507
|
+
return {
|
|
508
|
+
payload: await deps.decodeRsc(wrappedPromise),
|
|
509
|
+
headElements,
|
|
510
|
+
segmentInfo,
|
|
511
|
+
params
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
const response = await deps.fetch(rscUrl, {
|
|
515
|
+
headers,
|
|
516
|
+
redirect: "manual"
|
|
517
|
+
});
|
|
518
|
+
if (response.status >= 300 && response.status < 400) {
|
|
519
|
+
const location = response.headers.get("Location");
|
|
520
|
+
if (location) throw new RedirectError(location);
|
|
521
|
+
}
|
|
522
|
+
return {
|
|
523
|
+
payload: await response.text(),
|
|
524
|
+
headElements: extractHeadElements(response),
|
|
525
|
+
segmentInfo: extractSegmentInfo(response),
|
|
526
|
+
params: extractParams(response)
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* Create a router instance. In production, called once at app hydration
|
|
531
|
+
* with real browser APIs. In tests, called with mock dependencies.
|
|
532
|
+
*/
|
|
533
|
+
function createRouter(deps) {
|
|
534
|
+
const segmentCache = new SegmentCache();
|
|
535
|
+
const prefetchCache = new PrefetchCache();
|
|
536
|
+
const historyStack = new HistoryStack();
|
|
537
|
+
let pending = false;
|
|
538
|
+
let pendingUrl = null;
|
|
539
|
+
const pendingListeners = /* @__PURE__ */ new Set();
|
|
540
|
+
function setPending(value, url) {
|
|
541
|
+
const newPendingUrl = value && url ? url : null;
|
|
542
|
+
if (pending === value && pendingUrl === newPendingUrl) return;
|
|
543
|
+
pending = value;
|
|
544
|
+
pendingUrl = newPendingUrl;
|
|
545
|
+
for (const listener of pendingListeners) listener(value);
|
|
546
|
+
}
|
|
547
|
+
/** Update the segment cache from server-provided segment metadata. */
|
|
548
|
+
function updateSegmentCache(segmentInfo) {
|
|
549
|
+
if (!segmentInfo || segmentInfo.length === 0) return;
|
|
550
|
+
const tree = buildSegmentTree(segmentInfo);
|
|
551
|
+
if (tree) segmentCache.set("/", tree);
|
|
552
|
+
}
|
|
553
|
+
/** Render a decoded RSC payload into the DOM if a renderer is available. */
|
|
554
|
+
function renderPayload(payload) {
|
|
555
|
+
if (deps.renderRoot) deps.renderRoot(payload);
|
|
556
|
+
}
|
|
557
|
+
/** Update useParams() with route params from the server response. */
|
|
558
|
+
function updateParams(params) {
|
|
559
|
+
setCurrentParams(params ?? {});
|
|
560
|
+
}
|
|
561
|
+
/** Apply head elements (title, meta tags) to the DOM if available. */
|
|
562
|
+
function applyHead(elements) {
|
|
563
|
+
if (elements && deps.applyHead) deps.applyHead(elements);
|
|
564
|
+
}
|
|
565
|
+
/** Run a callback after the next paint (after React commit). */
|
|
566
|
+
function afterPaint(callback) {
|
|
567
|
+
if (deps.afterPaint) deps.afterPaint(callback);
|
|
568
|
+
else callback();
|
|
569
|
+
}
|
|
570
|
+
async function navigate(url, options = {}) {
|
|
571
|
+
const scroll = options.scroll !== false;
|
|
572
|
+
const replace = options.replace === true;
|
|
573
|
+
const currentScrollY = deps.getScrollY();
|
|
574
|
+
deps.replaceState({
|
|
575
|
+
timber: true,
|
|
576
|
+
scrollY: currentScrollY
|
|
577
|
+
}, "", deps.getCurrentUrl());
|
|
578
|
+
setPending(true, url);
|
|
579
|
+
try {
|
|
580
|
+
let result = prefetchCache.consume(url);
|
|
581
|
+
if (result === void 0) {
|
|
582
|
+
const stateTree = segmentCache.serializeStateTree();
|
|
583
|
+
const rawCurrentUrl = deps.getCurrentUrl();
|
|
584
|
+
result = await fetchRscPayload(url, deps, stateTree, rawCurrentUrl.startsWith("http") ? new URL(rawCurrentUrl).pathname : new URL(rawCurrentUrl, "http://localhost").pathname);
|
|
585
|
+
}
|
|
586
|
+
if (replace) deps.replaceState({
|
|
587
|
+
timber: true,
|
|
588
|
+
scrollY: 0
|
|
589
|
+
}, "", url);
|
|
590
|
+
else deps.pushState({
|
|
591
|
+
timber: true,
|
|
592
|
+
scrollY: 0
|
|
593
|
+
}, "", url);
|
|
594
|
+
historyStack.push(url, {
|
|
595
|
+
payload: result.payload,
|
|
596
|
+
headElements: result.headElements,
|
|
597
|
+
params: result.params
|
|
598
|
+
});
|
|
599
|
+
updateSegmentCache(result.segmentInfo);
|
|
600
|
+
updateParams(result.params);
|
|
601
|
+
renderPayload(result.payload);
|
|
602
|
+
applyHead(result.headElements);
|
|
603
|
+
window.dispatchEvent(new Event("timber:navigation-end"));
|
|
604
|
+
afterPaint(() => {
|
|
605
|
+
if (scroll) deps.scrollTo(0, 0);
|
|
606
|
+
else deps.scrollTo(0, currentScrollY);
|
|
607
|
+
window.dispatchEvent(new Event("timber:scroll-restored"));
|
|
608
|
+
});
|
|
609
|
+
} catch (error) {
|
|
610
|
+
if (error instanceof RedirectError) {
|
|
611
|
+
setPending(false);
|
|
612
|
+
await navigate(error.redirectUrl, { replace: true });
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
if (isAbortError(error)) return;
|
|
616
|
+
throw error;
|
|
617
|
+
} finally {
|
|
618
|
+
setPending(false);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
async function refresh() {
|
|
622
|
+
const currentUrl = deps.getCurrentUrl();
|
|
623
|
+
setPending(true, currentUrl);
|
|
624
|
+
try {
|
|
625
|
+
const result = await fetchRscPayload(currentUrl, deps);
|
|
626
|
+
historyStack.push(currentUrl, {
|
|
627
|
+
payload: result.payload,
|
|
628
|
+
headElements: result.headElements,
|
|
629
|
+
params: result.params
|
|
630
|
+
});
|
|
631
|
+
updateSegmentCache(result.segmentInfo);
|
|
632
|
+
updateParams(result.params);
|
|
633
|
+
renderPayload(result.payload);
|
|
634
|
+
applyHead(result.headElements);
|
|
635
|
+
} finally {
|
|
636
|
+
setPending(false);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
async function handlePopState(url, scrollY = 0) {
|
|
640
|
+
const entry = historyStack.get(url);
|
|
641
|
+
if (entry && entry.payload !== null) {
|
|
642
|
+
updateParams(entry.params);
|
|
643
|
+
renderPayload(entry.payload);
|
|
644
|
+
applyHead(entry.headElements);
|
|
645
|
+
afterPaint(() => {
|
|
646
|
+
deps.scrollTo(0, scrollY);
|
|
647
|
+
window.dispatchEvent(new Event("timber:scroll-restored"));
|
|
648
|
+
});
|
|
649
|
+
} else {
|
|
650
|
+
setPending(true, url);
|
|
651
|
+
try {
|
|
652
|
+
const result = await fetchRscPayload(url, deps, segmentCache.serializeStateTree());
|
|
653
|
+
updateSegmentCache(result.segmentInfo);
|
|
654
|
+
updateParams(result.params);
|
|
655
|
+
historyStack.push(url, {
|
|
656
|
+
payload: result.payload,
|
|
657
|
+
headElements: result.headElements,
|
|
658
|
+
params: result.params
|
|
659
|
+
});
|
|
660
|
+
renderPayload(result.payload);
|
|
661
|
+
applyHead(result.headElements);
|
|
662
|
+
afterPaint(() => {
|
|
663
|
+
deps.scrollTo(0, scrollY);
|
|
664
|
+
window.dispatchEvent(new Event("timber:scroll-restored"));
|
|
665
|
+
});
|
|
666
|
+
} finally {
|
|
667
|
+
setPending(false);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
/**
|
|
672
|
+
* Prefetch an RSC payload for a URL and store it in the prefetch cache.
|
|
673
|
+
* Called on hover of <Link prefetch> elements.
|
|
674
|
+
*/
|
|
675
|
+
function prefetch(url) {
|
|
676
|
+
if (prefetchCache.get(url) !== void 0) return;
|
|
677
|
+
if (historyStack.has(url)) return;
|
|
678
|
+
fetchRscPayload(url, deps, segmentCache.serializeStateTree()).then((result) => {
|
|
679
|
+
prefetchCache.set(url, result);
|
|
680
|
+
}, () => {});
|
|
681
|
+
}
|
|
682
|
+
return {
|
|
683
|
+
navigate,
|
|
684
|
+
refresh,
|
|
685
|
+
handlePopState,
|
|
686
|
+
isPending: () => pending,
|
|
687
|
+
getPendingUrl: () => pendingUrl,
|
|
688
|
+
onPendingChange(listener) {
|
|
689
|
+
pendingListeners.add(listener);
|
|
690
|
+
return () => pendingListeners.delete(listener);
|
|
691
|
+
},
|
|
692
|
+
prefetch,
|
|
693
|
+
applyRevalidation(element, headElements) {
|
|
694
|
+
const currentUrl = deps.getCurrentUrl();
|
|
695
|
+
historyStack.push(currentUrl, {
|
|
696
|
+
payload: element,
|
|
697
|
+
headElements
|
|
698
|
+
});
|
|
699
|
+
renderPayload(element);
|
|
700
|
+
applyHead(headElements);
|
|
701
|
+
},
|
|
702
|
+
initSegmentCache: (segments) => updateSegmentCache(segments),
|
|
703
|
+
segmentCache,
|
|
704
|
+
prefetchCache,
|
|
705
|
+
historyStack
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
//#endregion
|
|
709
|
+
//#region src/client/use-navigation-pending.ts
|
|
710
|
+
/**
|
|
711
|
+
* Returns true while an RSC navigation is in flight.
|
|
712
|
+
*
|
|
713
|
+
* The pending state is true from the moment the RSC fetch starts until
|
|
714
|
+
* React reconciliation completes. This includes the fetch itself,
|
|
715
|
+
* RSC stream parsing, and React tree reconciliation.
|
|
716
|
+
*
|
|
717
|
+
* It does NOT include Suspense streaming after the shell — only the
|
|
718
|
+
* initial shell reconciliation.
|
|
719
|
+
*
|
|
720
|
+
* ```tsx
|
|
721
|
+
* 'use client'
|
|
722
|
+
* import { useNavigationPending } from '@timber/app/client'
|
|
723
|
+
*
|
|
724
|
+
* export function NavBar() {
|
|
725
|
+
* const isPending = useNavigationPending()
|
|
726
|
+
* return (
|
|
727
|
+
* <nav className={isPending ? 'opacity-50' : ''}>
|
|
728
|
+
* <Link href="/dashboard">Dashboard</Link>
|
|
729
|
+
* </nav>
|
|
730
|
+
* )
|
|
731
|
+
* }
|
|
732
|
+
* ```
|
|
733
|
+
*/
|
|
734
|
+
function useNavigationPending() {
|
|
735
|
+
return useSyncExternalStore((callback) => {
|
|
736
|
+
return getRouter().onPendingChange(callback);
|
|
737
|
+
}, () => {
|
|
738
|
+
try {
|
|
739
|
+
return getRouter().isPending();
|
|
740
|
+
} catch {
|
|
741
|
+
return false;
|
|
742
|
+
}
|
|
743
|
+
}, () => false);
|
|
744
|
+
}
|
|
745
|
+
//#endregion
|
|
746
|
+
//#region src/client/use-router.ts
|
|
747
|
+
/**
|
|
748
|
+
* useRouter() — client-side hook for programmatic navigation.
|
|
749
|
+
*
|
|
750
|
+
* Returns a router instance with push, replace, refresh, back, forward,
|
|
751
|
+
* and prefetch methods. Compatible with Next.js's `useRouter()` from
|
|
752
|
+
* `next/navigation` (App Router).
|
|
753
|
+
*
|
|
754
|
+
* This wraps timber's internal RouterInstance in the Next.js-compatible
|
|
755
|
+
* AppRouterInstance shape that ecosystem libraries expect.
|
|
756
|
+
*/
|
|
757
|
+
/** No-op router returned during SSR or before bootstrap. All methods are safe no-ops. */
|
|
758
|
+
var SSR_NOOP_ROUTER = {
|
|
759
|
+
push() {},
|
|
760
|
+
replace() {},
|
|
761
|
+
refresh() {},
|
|
762
|
+
back() {},
|
|
763
|
+
forward() {},
|
|
764
|
+
prefetch() {}
|
|
765
|
+
};
|
|
766
|
+
/**
|
|
767
|
+
* Get a router instance for programmatic navigation.
|
|
768
|
+
*
|
|
769
|
+
* Compatible with Next.js's `useRouter()` from `next/navigation`.
|
|
770
|
+
*
|
|
771
|
+
* Returns a no-op router during SSR or before the client router is bootstrapped,
|
|
772
|
+
* so components that call useRouter() at the function level (e.g. TransitionLink)
|
|
773
|
+
* do not crash during server-side rendering.
|
|
774
|
+
*/
|
|
775
|
+
function useRouter() {
|
|
776
|
+
let router;
|
|
777
|
+
try {
|
|
778
|
+
router = getRouter();
|
|
779
|
+
} catch {
|
|
780
|
+
return SSR_NOOP_ROUTER;
|
|
781
|
+
}
|
|
782
|
+
return {
|
|
783
|
+
push(href, options) {
|
|
784
|
+
router.navigate(href, { scroll: options?.scroll });
|
|
785
|
+
},
|
|
786
|
+
replace(href, options) {
|
|
787
|
+
router.navigate(href, {
|
|
788
|
+
scroll: options?.scroll,
|
|
789
|
+
replace: true
|
|
790
|
+
});
|
|
791
|
+
},
|
|
792
|
+
refresh() {
|
|
793
|
+
router.refresh();
|
|
794
|
+
},
|
|
795
|
+
back() {
|
|
796
|
+
window.history.back();
|
|
797
|
+
},
|
|
798
|
+
forward() {
|
|
799
|
+
window.history.forward();
|
|
800
|
+
},
|
|
801
|
+
prefetch(href) {
|
|
802
|
+
router.prefetch(href);
|
|
803
|
+
}
|
|
804
|
+
};
|
|
805
|
+
}
|
|
806
|
+
//#endregion
|
|
807
|
+
//#region src/client/use-pathname.ts
|
|
808
|
+
/**
|
|
809
|
+
* usePathname() — client-side hook for reading the current pathname.
|
|
810
|
+
*
|
|
811
|
+
* Returns the pathname portion of the current URL (e.g. '/dashboard/settings').
|
|
812
|
+
* Updates when client-side navigation changes the URL.
|
|
813
|
+
*
|
|
814
|
+
* This is a thin wrapper over window.location.pathname, provided for
|
|
815
|
+
* Next.js API compatibility (libraries like nuqs import usePathname
|
|
816
|
+
* from next/navigation).
|
|
817
|
+
*
|
|
818
|
+
* During SSR, reads the request pathname from the SSR ALS context
|
|
819
|
+
* (populated by ssr-entry.ts) instead of window.location.
|
|
820
|
+
*/
|
|
821
|
+
function getPathname() {
|
|
822
|
+
if (typeof window !== "undefined") return window.location.pathname;
|
|
823
|
+
return getSsrData()?.pathname ?? "/";
|
|
824
|
+
}
|
|
825
|
+
function getServerPathname() {
|
|
826
|
+
return getSsrData()?.pathname ?? "/";
|
|
827
|
+
}
|
|
828
|
+
function subscribe$1(callback) {
|
|
829
|
+
window.addEventListener("popstate", callback);
|
|
830
|
+
return () => window.removeEventListener("popstate", callback);
|
|
831
|
+
}
|
|
832
|
+
/**
|
|
833
|
+
* Read the current URL pathname.
|
|
834
|
+
*
|
|
835
|
+
* Compatible with Next.js's `usePathname()` from `next/navigation`.
|
|
836
|
+
*/
|
|
837
|
+
function usePathname() {
|
|
838
|
+
return useSyncExternalStore(subscribe$1, getPathname, getServerPathname);
|
|
839
|
+
}
|
|
840
|
+
//#endregion
|
|
841
|
+
//#region src/client/use-search-params.ts
|
|
842
|
+
/**
|
|
843
|
+
* useSearchParams() — client-side hook for reading URL search params.
|
|
844
|
+
*
|
|
845
|
+
* Returns a read-only URLSearchParams instance reflecting the current
|
|
846
|
+
* URL's query string. Updates when client-side navigation changes the URL.
|
|
847
|
+
*
|
|
848
|
+
* This is a thin wrapper over window.location.search, provided for
|
|
849
|
+
* Next.js API compatibility (libraries like nuqs import useSearchParams
|
|
850
|
+
* from next/navigation).
|
|
851
|
+
*
|
|
852
|
+
* Unlike Next.js's ReadonlyURLSearchParams, this returns a standard
|
|
853
|
+
* URLSearchParams. Mutation methods (set, delete, append) work on the
|
|
854
|
+
* local copy but do NOT affect the URL — use the router or nuqs for that.
|
|
855
|
+
*
|
|
856
|
+
* During SSR, reads the request search params from the SSR ALS context
|
|
857
|
+
* (populated by ssr-entry.ts) instead of window.location.
|
|
858
|
+
*/
|
|
859
|
+
function getSearch() {
|
|
860
|
+
if (typeof window !== "undefined") return window.location.search;
|
|
861
|
+
const data = getSsrData();
|
|
862
|
+
if (!data) return "";
|
|
863
|
+
const str = new URLSearchParams(data.searchParams).toString();
|
|
864
|
+
return str ? `?${str}` : "";
|
|
865
|
+
}
|
|
866
|
+
function getServerSearch() {
|
|
867
|
+
const data = getSsrData();
|
|
868
|
+
if (!data) return "";
|
|
869
|
+
const str = new URLSearchParams(data.searchParams).toString();
|
|
870
|
+
return str ? `?${str}` : "";
|
|
871
|
+
}
|
|
872
|
+
function subscribe(callback) {
|
|
873
|
+
window.addEventListener("popstate", callback);
|
|
874
|
+
return () => window.removeEventListener("popstate", callback);
|
|
875
|
+
}
|
|
876
|
+
var cachedSearch = "";
|
|
877
|
+
var cachedParams = new URLSearchParams();
|
|
878
|
+
function getSearchParams() {
|
|
879
|
+
const search = getSearch();
|
|
880
|
+
if (search !== cachedSearch) {
|
|
881
|
+
cachedSearch = search;
|
|
882
|
+
cachedParams = new URLSearchParams(search);
|
|
883
|
+
}
|
|
884
|
+
return cachedParams;
|
|
885
|
+
}
|
|
886
|
+
function getServerSearchParams() {
|
|
887
|
+
const data = getSsrData();
|
|
888
|
+
return data ? new URLSearchParams(data.searchParams) : new URLSearchParams();
|
|
889
|
+
}
|
|
890
|
+
/**
|
|
891
|
+
* Read the current URL search params.
|
|
892
|
+
*
|
|
893
|
+
* Compatible with Next.js's `useSearchParams()` from `next/navigation`.
|
|
894
|
+
*/
|
|
895
|
+
function useSearchParams() {
|
|
896
|
+
useSyncExternalStore(subscribe, getSearch, getServerSearch);
|
|
897
|
+
return typeof window !== "undefined" ? getSearchParams() : getServerSearchParams();
|
|
898
|
+
}
|
|
899
|
+
//#endregion
|
|
900
|
+
//#region src/client/segment-context.ts
|
|
901
|
+
/**
|
|
902
|
+
* Segment Context — provides layout segment position for useSelectedLayoutSegment hooks.
|
|
903
|
+
*
|
|
904
|
+
* Each layout in the segment tree is wrapped with a SegmentProvider that stores
|
|
905
|
+
* the URL segments from root to the current layout level. The hooks read this
|
|
906
|
+
* context to determine which child segments are active below the calling layout.
|
|
907
|
+
*
|
|
908
|
+
* The context value is intentionally minimal: just the segment path array and
|
|
909
|
+
* parallel route keys. No internal cache details are exposed.
|
|
910
|
+
*
|
|
911
|
+
* Design docs: design/19-client-navigation.md, design/14-ecosystem.md
|
|
912
|
+
*/
|
|
913
|
+
var SegmentContext = createContext(null);
|
|
914
|
+
/** Read the segment context. Returns null if no provider is above this component. */
|
|
915
|
+
function useSegmentContext() {
|
|
916
|
+
return useContext(SegmentContext);
|
|
917
|
+
}
|
|
918
|
+
/**
|
|
919
|
+
* Wraps each layout to provide segment position context.
|
|
920
|
+
* Injected by rsc-entry.ts during element tree construction.
|
|
921
|
+
*/
|
|
922
|
+
function SegmentProvider({ segments, parallelRouteKeys, children }) {
|
|
923
|
+
const value = useMemo(() => ({
|
|
924
|
+
segments,
|
|
925
|
+
parallelRouteKeys
|
|
926
|
+
}), [segments.join("/"), parallelRouteKeys.join(",")]);
|
|
927
|
+
return createElement(SegmentContext.Provider, { value }, children);
|
|
928
|
+
}
|
|
929
|
+
//#endregion
|
|
930
|
+
//#region src/client/use-selected-layout-segment.ts
|
|
931
|
+
/**
|
|
932
|
+
* useSelectedLayoutSegment / useSelectedLayoutSegments — client-side hooks
|
|
933
|
+
* for reading the active segment(s) below the current layout.
|
|
934
|
+
*
|
|
935
|
+
* These hooks are used by navigation UIs to highlight active sections.
|
|
936
|
+
* They match Next.js's API from next/navigation.
|
|
937
|
+
*
|
|
938
|
+
* How they work:
|
|
939
|
+
* 1. Each layout is wrapped with a SegmentProvider that records its depth
|
|
940
|
+
* (the URL segments from root to that layout level).
|
|
941
|
+
* 2. The hooks read the current URL pathname via usePathname().
|
|
942
|
+
* 3. They compare the layout's segment depth against the full URL segments
|
|
943
|
+
* to determine which child segments are "selected" below.
|
|
944
|
+
*
|
|
945
|
+
* Example: For URL "/dashboard/settings/profile"
|
|
946
|
+
* - Root layout (depth 0, segments: ['']): selected segment = "dashboard"
|
|
947
|
+
* - Dashboard layout (depth 1, segments: ['', 'dashboard']): selected = "settings"
|
|
948
|
+
* - Settings layout (depth 2, segments: ['', 'dashboard', 'settings']): selected = "profile"
|
|
949
|
+
*
|
|
950
|
+
* Design docs: design/19-client-navigation.md, design/14-ecosystem.md
|
|
951
|
+
*/
|
|
952
|
+
/**
|
|
953
|
+
* Split a pathname into URL segments.
|
|
954
|
+
* "/" → [""]
|
|
955
|
+
* "/dashboard" → ["", "dashboard"]
|
|
956
|
+
* "/dashboard/settings" → ["", "dashboard", "settings"]
|
|
957
|
+
*/
|
|
958
|
+
function pathnameToSegments(pathname) {
|
|
959
|
+
return pathname.split("/");
|
|
960
|
+
}
|
|
961
|
+
/**
|
|
962
|
+
* Pure function: compute the selected child segment given a layout's segment
|
|
963
|
+
* depth and the current URL pathname.
|
|
964
|
+
*
|
|
965
|
+
* @param contextSegments — segments from root to the calling layout, or null if no context
|
|
966
|
+
* @param pathname — current URL pathname
|
|
967
|
+
* @returns the active child segment one level below, or null if at the leaf
|
|
968
|
+
*/
|
|
969
|
+
function getSelectedSegment(contextSegments, pathname) {
|
|
970
|
+
const urlSegments = pathnameToSegments(pathname);
|
|
971
|
+
if (!contextSegments) return urlSegments[1] || null;
|
|
972
|
+
return urlSegments[contextSegments.length] || null;
|
|
973
|
+
}
|
|
974
|
+
/**
|
|
975
|
+
* Pure function: compute all selected segments below a layout's depth.
|
|
976
|
+
*
|
|
977
|
+
* @param contextSegments — segments from root to the calling layout, or null if no context
|
|
978
|
+
* @param pathname — current URL pathname
|
|
979
|
+
* @returns all active segments below the layout
|
|
980
|
+
*/
|
|
981
|
+
function getSelectedSegments(contextSegments, pathname) {
|
|
982
|
+
const urlSegments = pathnameToSegments(pathname);
|
|
983
|
+
if (!contextSegments) return urlSegments.slice(1).filter(Boolean);
|
|
984
|
+
const depth = contextSegments.length;
|
|
985
|
+
return urlSegments.slice(depth).filter(Boolean);
|
|
986
|
+
}
|
|
987
|
+
/**
|
|
988
|
+
* Returns the active child segment one level below the layout where this
|
|
989
|
+
* hook is called. Returns `null` if the layout is the leaf (no child segment).
|
|
990
|
+
*
|
|
991
|
+
* Compatible with Next.js's `useSelectedLayoutSegment()` from `next/navigation`.
|
|
992
|
+
*
|
|
993
|
+
* @param parallelRouteKey — Optional parallel route key. Currently unused
|
|
994
|
+
* (parallel route segment tracking is not yet implemented). Accepted for
|
|
995
|
+
* API compatibility with Next.js.
|
|
996
|
+
*/
|
|
997
|
+
function useSelectedLayoutSegment(parallelRouteKey) {
|
|
998
|
+
const context = useSegmentContext();
|
|
999
|
+
const pathname = usePathname();
|
|
1000
|
+
return getSelectedSegment(context?.segments ?? null, pathname);
|
|
1001
|
+
}
|
|
1002
|
+
/**
|
|
1003
|
+
* Returns all active segments below the layout where this hook is called.
|
|
1004
|
+
* Returns an empty array if the layout is the leaf (no child segments).
|
|
1005
|
+
*
|
|
1006
|
+
* Compatible with Next.js's `useSelectedLayoutSegments()` from `next/navigation`.
|
|
1007
|
+
*
|
|
1008
|
+
* @param parallelRouteKey — Optional parallel route key. Currently unused
|
|
1009
|
+
* (parallel route segment tracking is not yet implemented). Accepted for
|
|
1010
|
+
* API compatibility with Next.js.
|
|
1011
|
+
*/
|
|
1012
|
+
function useSelectedLayoutSegments(parallelRouteKey) {
|
|
1013
|
+
const context = useSegmentContext();
|
|
1014
|
+
const pathname = usePathname();
|
|
1015
|
+
return getSelectedSegments(context?.segments ?? null, pathname);
|
|
1016
|
+
}
|
|
1017
|
+
//#endregion
|
|
1018
|
+
//#region src/client/form.tsx
|
|
1019
|
+
/**
|
|
1020
|
+
* Client-side form utilities for server actions.
|
|
1021
|
+
*
|
|
1022
|
+
* Exports a typed `useActionState` that understands the action builder's result shape.
|
|
1023
|
+
* Result is typed to:
|
|
1024
|
+
* { data: T } | { validationErrors: Record<string, string[]> } | { serverError: { code, data? } } | null
|
|
1025
|
+
*
|
|
1026
|
+
* The action builder emits a function that satisfies both the direct call signature
|
|
1027
|
+
* and React's `(prevState, formData) => Promise<State>` contract.
|
|
1028
|
+
*
|
|
1029
|
+
* See design/08-forms-and-actions.md §"Client-Side Form Mechanics"
|
|
1030
|
+
*/
|
|
1031
|
+
/**
|
|
1032
|
+
* Typed wrapper around React 19's `useActionState` that understands
|
|
1033
|
+
* the timber action builder's result shape.
|
|
1034
|
+
*
|
|
1035
|
+
* @param action - A server action created with createActionClient or a raw 'use server' function.
|
|
1036
|
+
* @param initialState - Initial state, typically `null`. Pass `getFormFlash()` for no-JS
|
|
1037
|
+
* progressive enhancement — the flash seeds the initial state so the form has a
|
|
1038
|
+
* single source of truth for both with-JS and no-JS paths.
|
|
1039
|
+
* @param permalink - Optional permalink for progressive enhancement (no-JS fallback URL).
|
|
1040
|
+
*
|
|
1041
|
+
* @example
|
|
1042
|
+
* ```tsx
|
|
1043
|
+
* 'use client'
|
|
1044
|
+
* import { useActionState } from '@timber/app/client'
|
|
1045
|
+
* import { createTodo } from './actions'
|
|
1046
|
+
*
|
|
1047
|
+
* export function NewTodoForm({ flash }) {
|
|
1048
|
+
* const [result, action, isPending] = useActionState(createTodo, flash)
|
|
1049
|
+
* return (
|
|
1050
|
+
* <form action={action}>
|
|
1051
|
+
* <input name="title" />
|
|
1052
|
+
* {result?.validationErrors?.title && <p>{result.validationErrors.title}</p>}
|
|
1053
|
+
* <button disabled={isPending}>Add</button>
|
|
1054
|
+
* </form>
|
|
1055
|
+
* )
|
|
1056
|
+
* }
|
|
1057
|
+
* ```
|
|
1058
|
+
*/
|
|
1059
|
+
function useActionState(action, initialState, permalink) {
|
|
1060
|
+
return useActionState$1(action, initialState, permalink);
|
|
1061
|
+
}
|
|
1062
|
+
/**
|
|
1063
|
+
* Hook for calling a server action imperatively (not via a form).
|
|
1064
|
+
* Returns [execute, isPending] where execute accepts the input directly.
|
|
1065
|
+
*
|
|
1066
|
+
* @example
|
|
1067
|
+
* ```tsx
|
|
1068
|
+
* const [deleteTodo, isPending] = useFormAction(deleteTodoAction)
|
|
1069
|
+
* <button onClick={() => deleteTodo({ id: todo.id })} disabled={isPending}>
|
|
1070
|
+
* Delete
|
|
1071
|
+
* </button>
|
|
1072
|
+
* ```
|
|
1073
|
+
*/
|
|
1074
|
+
function useFormAction(action) {
|
|
1075
|
+
const [isPending, startTransition] = useTransition();
|
|
1076
|
+
const execute = (input) => {
|
|
1077
|
+
return new Promise((resolve) => {
|
|
1078
|
+
startTransition(async () => {
|
|
1079
|
+
resolve(await action(input));
|
|
1080
|
+
});
|
|
1081
|
+
});
|
|
1082
|
+
};
|
|
1083
|
+
return [execute, isPending];
|
|
1084
|
+
}
|
|
1085
|
+
/**
|
|
1086
|
+
* Extract per-field and form-level errors from an ActionResult.
|
|
1087
|
+
*
|
|
1088
|
+
* Pure function (no internal hooks) — follows React naming convention
|
|
1089
|
+
* since it's used in render. Accepts the result from `useActionState`
|
|
1090
|
+
* or flash data from `getFormFlash()`.
|
|
1091
|
+
*
|
|
1092
|
+
* @example
|
|
1093
|
+
* ```tsx
|
|
1094
|
+
* const [result, action, isPending] = useActionState(createTodo, null)
|
|
1095
|
+
* const errors = useFormErrors(result)
|
|
1096
|
+
*
|
|
1097
|
+
* return (
|
|
1098
|
+
* <form action={action}>
|
|
1099
|
+
* <input name="title" />
|
|
1100
|
+
* {errors.getFieldError('title') && <p>{errors.getFieldError('title')}</p>}
|
|
1101
|
+
* {errors.formErrors.map(e => <p key={e}>{e}</p>)}
|
|
1102
|
+
* </form>
|
|
1103
|
+
* )
|
|
1104
|
+
* ```
|
|
1105
|
+
*/
|
|
1106
|
+
function useFormErrors(result) {
|
|
1107
|
+
const empty = {
|
|
1108
|
+
fieldErrors: {},
|
|
1109
|
+
formErrors: [],
|
|
1110
|
+
serverError: null,
|
|
1111
|
+
hasErrors: false,
|
|
1112
|
+
getFieldError: () => null
|
|
1113
|
+
};
|
|
1114
|
+
if (!result) return empty;
|
|
1115
|
+
const validationErrors = result.validationErrors;
|
|
1116
|
+
const serverError = result.serverError;
|
|
1117
|
+
if (!validationErrors && !serverError) return empty;
|
|
1118
|
+
const fieldErrors = {};
|
|
1119
|
+
const formErrors = [];
|
|
1120
|
+
if (validationErrors) for (const [key, messages] of Object.entries(validationErrors)) if (key === "_root") formErrors.push(...messages);
|
|
1121
|
+
else fieldErrors[key] = messages;
|
|
1122
|
+
const hasErrors = Object.keys(fieldErrors).length > 0 || formErrors.length > 0 || serverError != null;
|
|
1123
|
+
return {
|
|
1124
|
+
fieldErrors,
|
|
1125
|
+
formErrors,
|
|
1126
|
+
serverError: serverError ?? null,
|
|
1127
|
+
hasErrors,
|
|
1128
|
+
getFieldError(field) {
|
|
1129
|
+
const errs = fieldErrors[field];
|
|
1130
|
+
return errs && errs.length > 0 ? errs[0] : null;
|
|
1131
|
+
}
|
|
1132
|
+
};
|
|
1133
|
+
}
|
|
1134
|
+
//#endregion
|
|
1135
|
+
//#region src/client/use-query-states.ts
|
|
1136
|
+
/**
|
|
1137
|
+
* useQueryStates — client-side hook for URL-synced search params.
|
|
1138
|
+
*
|
|
1139
|
+
* Delegates to nuqs for URL synchronization, batching, React 19 transitions,
|
|
1140
|
+
* and throttled URL writes. Bridges timber's SearchParamCodec protocol to
|
|
1141
|
+
* nuqs-compatible parsers.
|
|
1142
|
+
*
|
|
1143
|
+
* Design doc: design/23-search-params.md §"Codec Bridge"
|
|
1144
|
+
*/
|
|
1145
|
+
/**
|
|
1146
|
+
* Bridge a timber SearchParamCodec to a nuqs-compatible SingleParser.
|
|
1147
|
+
*
|
|
1148
|
+
* nuqs parsers: { parse(string) → T|null, serialize?(T) → string, eq?, defaultValue? }
|
|
1149
|
+
* timber codecs: { parse(string|string[]|undefined) → T, serialize(T) → string|null }
|
|
1150
|
+
*/
|
|
1151
|
+
function bridgeCodec(codec) {
|
|
1152
|
+
return {
|
|
1153
|
+
parse: (v) => codec.parse(v),
|
|
1154
|
+
serialize: (v) => codec.serialize(v) ?? "",
|
|
1155
|
+
defaultValue: codec.parse(void 0),
|
|
1156
|
+
eq: (a, b) => codec.serialize(a) === codec.serialize(b)
|
|
1157
|
+
};
|
|
1158
|
+
}
|
|
1159
|
+
/**
|
|
1160
|
+
* Bridge an entire codec map to nuqs-compatible parsers.
|
|
1161
|
+
*/
|
|
1162
|
+
function bridgeCodecs(codecs) {
|
|
1163
|
+
const result = {};
|
|
1164
|
+
for (const key of Object.keys(codecs)) result[key] = bridgeCodec(codecs[key]);
|
|
1165
|
+
return result;
|
|
1166
|
+
}
|
|
1167
|
+
/**
|
|
1168
|
+
* Read and write typed search params from/to the URL.
|
|
1169
|
+
*
|
|
1170
|
+
* Delegates to nuqs internally. The timber nuqs adapter (auto-injected in
|
|
1171
|
+
* browser-entry.ts) handles RSC navigation on non-shallow updates.
|
|
1172
|
+
*
|
|
1173
|
+
* Usage:
|
|
1174
|
+
* ```ts
|
|
1175
|
+
* // Via a SearchParamsDefinition
|
|
1176
|
+
* const [params, setParams] = definition.useQueryStates()
|
|
1177
|
+
*
|
|
1178
|
+
* // Standalone with inline codecs
|
|
1179
|
+
* const [params, setParams] = useQueryStates({
|
|
1180
|
+
* page: fromSchema(z.coerce.number().int().min(1).default(1)),
|
|
1181
|
+
* })
|
|
1182
|
+
* ```
|
|
1183
|
+
*/
|
|
1184
|
+
function useQueryStates(codecsOrRoute, _options, urlKeys) {
|
|
1185
|
+
let codecs;
|
|
1186
|
+
let resolvedUrlKeys = urlKeys;
|
|
1187
|
+
if (typeof codecsOrRoute === "string") {
|
|
1188
|
+
const definition = getSearchParams$1(codecsOrRoute);
|
|
1189
|
+
if (!definition) throw new Error(`useQueryStates('${codecsOrRoute}'): no search params registered for this route. Either the route has no search-params.ts file, or it hasn't been loaded yet. For cross-route usage, import the definition explicitly.`);
|
|
1190
|
+
codecs = definition.codecs;
|
|
1191
|
+
resolvedUrlKeys = definition.urlKeys;
|
|
1192
|
+
} else codecs = codecsOrRoute;
|
|
1193
|
+
const bridged = bridgeCodecs(codecs);
|
|
1194
|
+
const nuqsOptions = {};
|
|
1195
|
+
if (resolvedUrlKeys && Object.keys(resolvedUrlKeys).length > 0) nuqsOptions.urlKeys = resolvedUrlKeys;
|
|
1196
|
+
const [values, setValues] = useQueryStates$1(bridged, nuqsOptions);
|
|
1197
|
+
const setParams = (partial, setOptions) => {
|
|
1198
|
+
const nuqsSetOptions = {};
|
|
1199
|
+
if (setOptions?.shallow !== void 0) nuqsSetOptions.shallow = setOptions.shallow;
|
|
1200
|
+
if (setOptions?.scroll !== void 0) nuqsSetOptions.scroll = setOptions.scroll;
|
|
1201
|
+
if (setOptions?.history !== void 0) nuqsSetOptions.history = setOptions.history;
|
|
1202
|
+
setValues(partial, nuqsSetOptions);
|
|
1203
|
+
};
|
|
1204
|
+
return [values, setParams];
|
|
1205
|
+
}
|
|
1206
|
+
/**
|
|
1207
|
+
* Create a useQueryStates binding for a SearchParamsDefinition.
|
|
1208
|
+
* This is used internally by SearchParamsDefinition.useQueryStates().
|
|
1209
|
+
*/
|
|
1210
|
+
function bindUseQueryStates(definition) {
|
|
1211
|
+
return (options) => {
|
|
1212
|
+
return useQueryStates(definition.codecs, options, definition.urlKeys);
|
|
1213
|
+
};
|
|
1214
|
+
}
|
|
1215
|
+
//#endregion
|
|
1216
|
+
export { HistoryStack, Link, LinkStatusContext, PrefetchCache, SegmentCache, SegmentProvider, TimberErrorBoundary, bindUseQueryStates, buildLinkProps, clearSsrData, createRouter, getRouter, getSsrData, interpolateParams, resolveHref, setCurrentParams, setSsrData, useActionState, useCookie, useFormAction, useFormErrors, useLinkStatus, useNavigationPending, useParams, usePathname, useQueryStates, useRouter, useSearchParams, useSegmentContext, useSelectedLayoutSegment, useSelectedLayoutSegments, validateLinkHref };
|
|
1217
|
+
|
|
1218
|
+
//# sourceMappingURL=index.js.map
|