@timber-js/app 0.1.1 → 0.1.2
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/dist/index.js +5 -2
- package/dist/index.js.map +1 -1
- package/dist/plugins/entries.d.ts.map +1 -1
- package/package.json +2 -1
- package/src/adapters/cloudflare.ts +325 -0
- package/src/adapters/nitro.ts +366 -0
- package/src/adapters/types.ts +63 -0
- package/src/cache/index.ts +91 -0
- package/src/cache/redis-handler.ts +91 -0
- package/src/cache/register-cached-function.ts +99 -0
- package/src/cache/singleflight.ts +26 -0
- package/src/cache/stable-stringify.ts +21 -0
- package/src/cache/timber-cache.ts +116 -0
- package/src/cli.ts +201 -0
- package/src/client/browser-entry.ts +663 -0
- package/src/client/error-boundary.tsx +209 -0
- package/src/client/form.tsx +200 -0
- package/src/client/head.ts +61 -0
- package/src/client/history.ts +46 -0
- package/src/client/index.ts +60 -0
- package/src/client/link-navigate-interceptor.tsx +62 -0
- package/src/client/link-status-provider.tsx +40 -0
- package/src/client/link.tsx +310 -0
- package/src/client/nuqs-adapter.tsx +117 -0
- package/src/client/router-ref.ts +25 -0
- package/src/client/router.ts +563 -0
- package/src/client/segment-cache.ts +194 -0
- package/src/client/segment-context.ts +57 -0
- package/src/client/ssr-data.ts +95 -0
- package/src/client/types.ts +4 -0
- package/src/client/unload-guard.ts +34 -0
- package/src/client/use-cookie.ts +122 -0
- package/src/client/use-link-status.ts +46 -0
- package/src/client/use-navigation-pending.ts +47 -0
- package/src/client/use-params.ts +71 -0
- package/src/client/use-pathname.ts +43 -0
- package/src/client/use-query-states.ts +133 -0
- package/src/client/use-router.ts +77 -0
- package/src/client/use-search-params.ts +74 -0
- package/src/client/use-selected-layout-segment.ts +110 -0
- package/src/content/index.ts +13 -0
- package/src/cookies/define-cookie.ts +137 -0
- package/src/cookies/index.ts +9 -0
- package/src/fonts/ast.ts +359 -0
- package/src/fonts/css.ts +68 -0
- package/src/fonts/fallbacks.ts +248 -0
- package/src/fonts/google.ts +332 -0
- package/src/fonts/local.ts +177 -0
- package/src/fonts/types.ts +88 -0
- package/src/index.ts +413 -0
- package/src/plugins/adapter-build.ts +118 -0
- package/src/plugins/build-manifest.ts +323 -0
- package/src/plugins/build-report.ts +353 -0
- package/src/plugins/cache-transform.ts +199 -0
- package/src/plugins/chunks.ts +90 -0
- package/src/plugins/content.ts +136 -0
- package/src/plugins/dev-error-overlay.ts +230 -0
- package/src/plugins/dev-logs.ts +280 -0
- package/src/plugins/dev-server.ts +389 -0
- package/src/plugins/dynamic-transform.ts +161 -0
- package/src/plugins/entries.ts +207 -0
- package/src/plugins/fonts.ts +581 -0
- package/src/plugins/mdx.ts +179 -0
- package/src/plugins/react-prod.ts +56 -0
- package/src/plugins/routing.ts +419 -0
- package/src/plugins/server-action-exports.ts +220 -0
- package/src/plugins/server-bundle.ts +113 -0
- package/src/plugins/shims.ts +168 -0
- package/src/plugins/static-build.ts +207 -0
- package/src/routing/codegen.ts +396 -0
- package/src/routing/index.ts +14 -0
- package/src/routing/interception.ts +173 -0
- package/src/routing/scanner.ts +487 -0
- package/src/routing/status-file-lint.ts +114 -0
- package/src/routing/types.ts +100 -0
- package/src/search-params/analyze.ts +192 -0
- package/src/search-params/codecs.ts +153 -0
- package/src/search-params/create.ts +314 -0
- package/src/search-params/index.ts +23 -0
- package/src/search-params/registry.ts +31 -0
- package/src/server/access-gate.tsx +142 -0
- package/src/server/action-client.ts +473 -0
- package/src/server/action-handler.ts +325 -0
- package/src/server/actions.ts +236 -0
- package/src/server/asset-headers.ts +81 -0
- package/src/server/body-limits.ts +102 -0
- package/src/server/build-manifest.ts +234 -0
- package/src/server/canonicalize.ts +90 -0
- package/src/server/client-module-map.ts +58 -0
- package/src/server/csrf.ts +79 -0
- package/src/server/deny-renderer.ts +302 -0
- package/src/server/dev-logger.ts +419 -0
- package/src/server/dev-span-processor.ts +78 -0
- package/src/server/dev-warnings.ts +282 -0
- package/src/server/early-hints-sender.ts +55 -0
- package/src/server/early-hints.ts +142 -0
- package/src/server/error-boundary-wrapper.ts +69 -0
- package/src/server/error-formatter.ts +184 -0
- package/src/server/flush.ts +182 -0
- package/src/server/form-data.ts +176 -0
- package/src/server/form-flash.ts +93 -0
- package/src/server/html-injectors.ts +445 -0
- package/src/server/index.ts +222 -0
- package/src/server/instrumentation.ts +136 -0
- package/src/server/logger.ts +145 -0
- package/src/server/manifest-status-resolver.ts +215 -0
- package/src/server/metadata-render.ts +527 -0
- package/src/server/metadata-routes.ts +189 -0
- package/src/server/metadata.ts +263 -0
- package/src/server/middleware-runner.ts +32 -0
- package/src/server/nuqs-ssr-provider.tsx +63 -0
- package/src/server/pipeline.ts +555 -0
- package/src/server/prerender.ts +139 -0
- package/src/server/primitives.ts +264 -0
- package/src/server/proxy.ts +43 -0
- package/src/server/request-context.ts +554 -0
- package/src/server/route-element-builder.ts +395 -0
- package/src/server/route-handler.ts +153 -0
- package/src/server/route-matcher.ts +316 -0
- package/src/server/rsc-entry/api-handler.ts +112 -0
- package/src/server/rsc-entry/error-renderer.ts +177 -0
- package/src/server/rsc-entry/helpers.ts +147 -0
- package/src/server/rsc-entry/index.ts +688 -0
- package/src/server/rsc-entry/ssr-bridge.ts +18 -0
- package/src/server/slot-resolver.ts +359 -0
- package/src/server/ssr-entry.ts +161 -0
- package/src/server/ssr-render.ts +200 -0
- package/src/server/status-code-resolver.ts +282 -0
- package/src/server/tracing.ts +281 -0
- package/src/server/tree-builder.ts +354 -0
- package/src/server/types.ts +150 -0
- package/src/shims/font-google.ts +67 -0
- package/src/shims/headers.ts +11 -0
- package/src/shims/image.ts +48 -0
- package/src/shims/link.ts +9 -0
- package/src/shims/navigation-client.ts +52 -0
- package/src/shims/navigation.ts +31 -0
- package/src/shims/server-only-noop.js +5 -0
- package/src/utils/directive-parser.ts +529 -0
- package/src/utils/format.ts +10 -0
- package/src/utils/startup-timer.ts +102 -0
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Framework-injected React error boundary.
|
|
5
|
+
*
|
|
6
|
+
* Catches errors thrown by children and renders a fallback component
|
|
7
|
+
* with the appropriate props based on error type:
|
|
8
|
+
* - DenySignal (4xx) → { status, dangerouslyPassData }
|
|
9
|
+
* - RenderError (5xx) → { error, digest, reset }
|
|
10
|
+
* - Unhandled error → { error, digest: null, reset }
|
|
11
|
+
*
|
|
12
|
+
* The `status` prop controls which errors this boundary catches:
|
|
13
|
+
* - Specific code (e.g. 403) → only that status
|
|
14
|
+
* - Category (400) → any 4xx
|
|
15
|
+
* - Category (500) → any 5xx
|
|
16
|
+
* - Omitted → catches everything (error.tsx behavior)
|
|
17
|
+
*
|
|
18
|
+
* See design/10-error-handling.md §"Status-Code Files"
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { Component, createElement, type ReactNode } from 'react';
|
|
22
|
+
|
|
23
|
+
// ─── Page Unload Detection ───────────────────────────────────────────────────
|
|
24
|
+
// Track whether the page is being unloaded (user refreshed or navigated away).
|
|
25
|
+
// When this is true, error boundaries suppress activation — the error is from
|
|
26
|
+
// the aborted connection, not an application error.
|
|
27
|
+
let _isUnloading = false;
|
|
28
|
+
if (typeof window !== 'undefined') {
|
|
29
|
+
window.addEventListener('beforeunload', () => {
|
|
30
|
+
_isUnloading = true;
|
|
31
|
+
});
|
|
32
|
+
window.addEventListener('pagehide', () => {
|
|
33
|
+
_isUnloading = true;
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ─── Digest Types ────────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
/** Structured digest returned by RSC onError for DenySignal. */
|
|
40
|
+
interface DenyDigest {
|
|
41
|
+
type: 'deny';
|
|
42
|
+
status: number;
|
|
43
|
+
data: unknown;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Structured digest returned by RSC onError for RenderError. */
|
|
47
|
+
interface RenderErrorDigest {
|
|
48
|
+
type: 'render-error';
|
|
49
|
+
code: string;
|
|
50
|
+
data: unknown;
|
|
51
|
+
status: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Structured digest returned by RSC onError for RedirectSignal. */
|
|
55
|
+
interface RedirectDigest {
|
|
56
|
+
type: 'redirect';
|
|
57
|
+
location: string;
|
|
58
|
+
status: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
type ParsedDigest = DenyDigest | RenderErrorDigest | RedirectDigest;
|
|
62
|
+
|
|
63
|
+
// ─── Props & State ───────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
export interface TimberErrorBoundaryProps {
|
|
66
|
+
/** The component to render when an error is caught. */
|
|
67
|
+
fallbackComponent: (...args: unknown[]) => ReactNode;
|
|
68
|
+
/**
|
|
69
|
+
* Status code filter. If set, only catches errors matching this status.
|
|
70
|
+
* 400 = any 4xx, 500 = any 5xx, specific number = exact match.
|
|
71
|
+
*/
|
|
72
|
+
status?: number;
|
|
73
|
+
children: ReactNode;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
interface TimberErrorBoundaryState {
|
|
77
|
+
hasError: boolean;
|
|
78
|
+
error: Error | null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ─── Component ───────────────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
export class TimberErrorBoundary extends Component<
|
|
84
|
+
TimberErrorBoundaryProps,
|
|
85
|
+
TimberErrorBoundaryState
|
|
86
|
+
> {
|
|
87
|
+
constructor(props: TimberErrorBoundaryProps) {
|
|
88
|
+
super(props);
|
|
89
|
+
this.state = { hasError: false, error: null };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
static getDerivedStateFromError(error: Error): TimberErrorBoundaryState {
|
|
93
|
+
// Suppress error boundaries during page unload (refresh/navigate away).
|
|
94
|
+
// The aborted connection causes React's streaming hydration to error,
|
|
95
|
+
// but the page is about to be replaced — showing an error boundary
|
|
96
|
+
// would be a jarring flash for the user.
|
|
97
|
+
if (_isUnloading) {
|
|
98
|
+
return { hasError: false, error: null };
|
|
99
|
+
}
|
|
100
|
+
return { hasError: true, error };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
componentDidUpdate(prevProps: TimberErrorBoundaryProps): void {
|
|
104
|
+
// Reset error state when children change (e.g. client-side navigation).
|
|
105
|
+
// Without this, navigating from one error page to another keeps the
|
|
106
|
+
// stale error — getDerivedStateFromError doesn't re-fire for new children.
|
|
107
|
+
if (this.state.hasError && prevProps.children !== this.props.children) {
|
|
108
|
+
this.setState({ hasError: false, error: null });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Reset the error state so children re-render. */
|
|
113
|
+
private reset = () => {
|
|
114
|
+
this.setState({ hasError: false, error: null });
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
render(): ReactNode {
|
|
118
|
+
if (!this.state.hasError || !this.state.error) {
|
|
119
|
+
return this.props.children;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const error = this.state.error;
|
|
123
|
+
const parsed = parseDigest(error);
|
|
124
|
+
|
|
125
|
+
// RedirectSignal errors must propagate through all error boundaries
|
|
126
|
+
// so the SSR shell fails and the pipeline catch block can produce a
|
|
127
|
+
// proper HTTP redirect response. See design/04-authorization.md.
|
|
128
|
+
if (parsed?.type === 'redirect') {
|
|
129
|
+
throw error;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// If this boundary has a status filter, check whether the error matches.
|
|
133
|
+
// Non-matching errors re-throw so an outer boundary can catch them.
|
|
134
|
+
if (this.props.status != null) {
|
|
135
|
+
const errorStatus = getErrorStatus(parsed, error);
|
|
136
|
+
if (errorStatus == null || !statusMatches(this.props.status, errorStatus)) {
|
|
137
|
+
// Re-throw: this boundary doesn't handle this error.
|
|
138
|
+
throw error;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Render the fallback component with the right props shape.
|
|
143
|
+
if (parsed?.type === 'deny') {
|
|
144
|
+
return createElement(this.props.fallbackComponent as never, {
|
|
145
|
+
status: parsed.status,
|
|
146
|
+
dangerouslyPassData: parsed.data,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// 5xx / RenderError / unhandled error
|
|
151
|
+
const digest =
|
|
152
|
+
parsed?.type === 'render-error' ? { code: parsed.code, data: parsed.data } : null;
|
|
153
|
+
|
|
154
|
+
return createElement(this.props.fallbackComponent as never, {
|
|
155
|
+
error,
|
|
156
|
+
digest,
|
|
157
|
+
reset: this.reset,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Parse the structured digest from the error.
|
|
166
|
+
* React sets `error.digest` from the string returned by RSC's onError.
|
|
167
|
+
*/
|
|
168
|
+
function parseDigest(error: Error): ParsedDigest | null {
|
|
169
|
+
const raw = (error as { digest?: string }).digest;
|
|
170
|
+
if (typeof raw !== 'string') return null;
|
|
171
|
+
try {
|
|
172
|
+
const parsed = JSON.parse(raw);
|
|
173
|
+
if (parsed && typeof parsed === 'object' && typeof parsed.type === 'string') {
|
|
174
|
+
return parsed as ParsedDigest;
|
|
175
|
+
}
|
|
176
|
+
} catch {
|
|
177
|
+
// Not JSON — legacy or unknown digest format
|
|
178
|
+
}
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Extract the HTTP status code from a parsed digest or error message.
|
|
184
|
+
* Falls back to message pattern matching for errors without a digest.
|
|
185
|
+
*/
|
|
186
|
+
function getErrorStatus(parsed: ParsedDigest | null, error: Error): number | null {
|
|
187
|
+
if (parsed?.type === 'deny') return parsed.status;
|
|
188
|
+
if (parsed?.type === 'render-error') return parsed.status;
|
|
189
|
+
if (parsed?.type === 'redirect') return parsed.status;
|
|
190
|
+
|
|
191
|
+
// Fallback: parse DenySignal message pattern for errors that lost their digest
|
|
192
|
+
const match = error.message.match(/^Access denied with status (\d+)$/);
|
|
193
|
+
if (match) return parseInt(match[1], 10);
|
|
194
|
+
|
|
195
|
+
// Unhandled errors are implicitly 500
|
|
196
|
+
return 500;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Check whether an error's status matches the boundary's status filter.
|
|
201
|
+
* Category markers (400, 500) match any status in that range.
|
|
202
|
+
*/
|
|
203
|
+
function statusMatches(boundaryStatus: number, errorStatus: number): boolean {
|
|
204
|
+
// Category catch-all: 400 matches any 4xx, 500 matches any 5xx
|
|
205
|
+
if (boundaryStatus === 400) return errorStatus >= 400 && errorStatus <= 499;
|
|
206
|
+
if (boundaryStatus === 500) return errorStatus >= 500 && errorStatus <= 599;
|
|
207
|
+
// Exact match
|
|
208
|
+
return boundaryStatus === errorStatus;
|
|
209
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side form utilities for server actions.
|
|
3
|
+
*
|
|
4
|
+
* Exports a typed `useActionState` that understands the action builder's result shape.
|
|
5
|
+
* Result is typed to:
|
|
6
|
+
* { data: T } | { validationErrors: Record<string, string[]> } | { serverError: { code, data? } } | null
|
|
7
|
+
*
|
|
8
|
+
* The action builder emits a function that satisfies both the direct call signature
|
|
9
|
+
* and React's `(prevState, formData) => Promise<State>` contract.
|
|
10
|
+
*
|
|
11
|
+
* See design/08-forms-and-actions.md §"Client-Side Form Mechanics"
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { useActionState as reactUseActionState, useTransition } from 'react';
|
|
15
|
+
import type { ActionResult, ValidationErrors } from '#/server/action-client';
|
|
16
|
+
import type { FormFlashData } from '#/server/form-flash';
|
|
17
|
+
|
|
18
|
+
// ─── Types ───────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The action function type accepted by useActionState.
|
|
22
|
+
* Must satisfy React's (prevState, formData) => Promise<State> contract.
|
|
23
|
+
*/
|
|
24
|
+
export type UseActionStateFn<TData> = (
|
|
25
|
+
prevState: ActionResult<TData> | null,
|
|
26
|
+
formData: FormData
|
|
27
|
+
) => Promise<ActionResult<TData>>;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Return type of useActionState — matches React 19's useActionState return.
|
|
31
|
+
* [result, formAction, isPending]
|
|
32
|
+
*/
|
|
33
|
+
export type UseActionStateReturn<TData> = [
|
|
34
|
+
result: ActionResult<TData> | null,
|
|
35
|
+
formAction: (formData: FormData) => void,
|
|
36
|
+
isPending: boolean,
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
// ─── useActionState ──────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Typed wrapper around React 19's `useActionState` that understands
|
|
43
|
+
* the timber action builder's result shape.
|
|
44
|
+
*
|
|
45
|
+
* @param action - A server action created with createActionClient or a raw 'use server' function.
|
|
46
|
+
* @param initialState - Initial state, typically `null`. Pass `getFormFlash()` for no-JS
|
|
47
|
+
* progressive enhancement — the flash seeds the initial state so the form has a
|
|
48
|
+
* single source of truth for both with-JS and no-JS paths.
|
|
49
|
+
* @param permalink - Optional permalink for progressive enhancement (no-JS fallback URL).
|
|
50
|
+
*
|
|
51
|
+
* @example
|
|
52
|
+
* ```tsx
|
|
53
|
+
* 'use client'
|
|
54
|
+
* import { useActionState } from '@timber/app/client'
|
|
55
|
+
* import { createTodo } from './actions'
|
|
56
|
+
*
|
|
57
|
+
* export function NewTodoForm({ flash }) {
|
|
58
|
+
* const [result, action, isPending] = useActionState(createTodo, flash)
|
|
59
|
+
* return (
|
|
60
|
+
* <form action={action}>
|
|
61
|
+
* <input name="title" />
|
|
62
|
+
* {result?.validationErrors?.title && <p>{result.validationErrors.title}</p>}
|
|
63
|
+
* <button disabled={isPending}>Add</button>
|
|
64
|
+
* </form>
|
|
65
|
+
* )
|
|
66
|
+
* }
|
|
67
|
+
* ```
|
|
68
|
+
*/
|
|
69
|
+
export function useActionState<TData>(
|
|
70
|
+
action: UseActionStateFn<TData>,
|
|
71
|
+
initialState: ActionResult<TData> | FormFlashData | null,
|
|
72
|
+
permalink?: string
|
|
73
|
+
): UseActionStateReturn<TData> {
|
|
74
|
+
// FormFlashData is structurally compatible with ActionResult at runtime —
|
|
75
|
+
// the cast satisfies React's generic inference which would otherwise widen TData.
|
|
76
|
+
return reactUseActionState(action, initialState as ActionResult<TData> | null, permalink);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ─── useFormAction ───────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Hook for calling a server action imperatively (not via a form).
|
|
83
|
+
* Returns [execute, isPending] where execute accepts the input directly.
|
|
84
|
+
*
|
|
85
|
+
* @example
|
|
86
|
+
* ```tsx
|
|
87
|
+
* const [deleteTodo, isPending] = useFormAction(deleteTodoAction)
|
|
88
|
+
* <button onClick={() => deleteTodo({ id: todo.id })} disabled={isPending}>
|
|
89
|
+
* Delete
|
|
90
|
+
* </button>
|
|
91
|
+
* ```
|
|
92
|
+
*/
|
|
93
|
+
export function useFormAction<TData>(
|
|
94
|
+
action: (input: unknown) => Promise<ActionResult<TData>>
|
|
95
|
+
): [(input?: unknown) => Promise<ActionResult<TData>>, boolean] {
|
|
96
|
+
const [isPending, startTransition] = useTransition();
|
|
97
|
+
|
|
98
|
+
const execute = (input?: unknown): Promise<ActionResult<TData>> => {
|
|
99
|
+
return new Promise((resolve) => {
|
|
100
|
+
startTransition(async () => {
|
|
101
|
+
const result = await action(input);
|
|
102
|
+
resolve(result);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
return [execute, isPending];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ─── useFormErrors ──────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
/** Return type of useFormErrors(). */
|
|
113
|
+
export interface FormErrorsResult {
|
|
114
|
+
/** Per-field validation errors keyed by field name. */
|
|
115
|
+
fieldErrors: Record<string, string[]>;
|
|
116
|
+
/** Form-level errors (from `_root` key). */
|
|
117
|
+
formErrors: string[];
|
|
118
|
+
/** Server error if the action threw an ActionError. */
|
|
119
|
+
serverError: { code: string; data?: Record<string, unknown> } | null;
|
|
120
|
+
/** Whether any errors are present. */
|
|
121
|
+
hasErrors: boolean;
|
|
122
|
+
/** Get the first error message for a field, or null. */
|
|
123
|
+
getFieldError: (field: string) => string | null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Extract per-field and form-level errors from an ActionResult.
|
|
128
|
+
*
|
|
129
|
+
* Pure function (no internal hooks) — follows React naming convention
|
|
130
|
+
* since it's used in render. Accepts the result from `useActionState`
|
|
131
|
+
* or flash data from `getFormFlash()`.
|
|
132
|
+
*
|
|
133
|
+
* @example
|
|
134
|
+
* ```tsx
|
|
135
|
+
* const [result, action, isPending] = useActionState(createTodo, null)
|
|
136
|
+
* const errors = useFormErrors(result)
|
|
137
|
+
*
|
|
138
|
+
* return (
|
|
139
|
+
* <form action={action}>
|
|
140
|
+
* <input name="title" />
|
|
141
|
+
* {errors.getFieldError('title') && <p>{errors.getFieldError('title')}</p>}
|
|
142
|
+
* {errors.formErrors.map(e => <p key={e}>{e}</p>)}
|
|
143
|
+
* </form>
|
|
144
|
+
* )
|
|
145
|
+
* ```
|
|
146
|
+
*/
|
|
147
|
+
export function useFormErrors<TData>(
|
|
148
|
+
result:
|
|
149
|
+
| ActionResult<TData>
|
|
150
|
+
| {
|
|
151
|
+
validationErrors?: ValidationErrors;
|
|
152
|
+
serverError?: { code: string; data?: Record<string, unknown> };
|
|
153
|
+
}
|
|
154
|
+
| null
|
|
155
|
+
): FormErrorsResult {
|
|
156
|
+
const empty: FormErrorsResult = {
|
|
157
|
+
fieldErrors: {},
|
|
158
|
+
formErrors: [],
|
|
159
|
+
serverError: null,
|
|
160
|
+
hasErrors: false,
|
|
161
|
+
getFieldError: () => null,
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
if (!result) return empty;
|
|
165
|
+
|
|
166
|
+
const validationErrors = result.validationErrors as ValidationErrors | undefined;
|
|
167
|
+
const serverError = result.serverError as
|
|
168
|
+
| { code: string; data?: Record<string, unknown> }
|
|
169
|
+
| undefined;
|
|
170
|
+
|
|
171
|
+
if (!validationErrors && !serverError) return empty;
|
|
172
|
+
|
|
173
|
+
// Separate _root (form-level) errors from field errors
|
|
174
|
+
const fieldErrors: Record<string, string[]> = {};
|
|
175
|
+
const formErrors: string[] = [];
|
|
176
|
+
|
|
177
|
+
if (validationErrors) {
|
|
178
|
+
for (const [key, messages] of Object.entries(validationErrors)) {
|
|
179
|
+
if (key === '_root') {
|
|
180
|
+
formErrors.push(...messages);
|
|
181
|
+
} else {
|
|
182
|
+
fieldErrors[key] = messages;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const hasErrors =
|
|
188
|
+
Object.keys(fieldErrors).length > 0 || formErrors.length > 0 || serverError != null;
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
fieldErrors,
|
|
192
|
+
formErrors,
|
|
193
|
+
serverError: serverError ?? null,
|
|
194
|
+
hasErrors,
|
|
195
|
+
getFieldError(field: string): string | null {
|
|
196
|
+
const errs = fieldErrors[field];
|
|
197
|
+
return errs && errs.length > 0 ? errs[0] : null;
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Client-side head element updates for SPA navigation.
|
|
2
|
+
//
|
|
3
|
+
// On RSC payload responses, the server sends resolved HeadElement[] via the
|
|
4
|
+
// X-Timber-Head response header. This module applies those elements to the
|
|
5
|
+
// DOM so document.title and <meta> tags stay current after navigation.
|
|
6
|
+
//
|
|
7
|
+
// See design/16-metadata.md
|
|
8
|
+
|
|
9
|
+
// ─── Types ───────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
/** Marker attribute for timber-managed head elements (cleanup on next navigation). */
|
|
12
|
+
const TIMBER_ATTR = 'data-timber-head';
|
|
13
|
+
|
|
14
|
+
/** A rendered head element descriptor (matches server-side HeadElement from metadata.ts). */
|
|
15
|
+
export interface HeadElement {
|
|
16
|
+
tag: 'title' | 'meta' | 'link';
|
|
17
|
+
content?: string;
|
|
18
|
+
attrs?: Record<string, string>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// ─── Apply Head Elements ─────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Apply resolved head elements to the DOM.
|
|
25
|
+
*
|
|
26
|
+
* - Sets document.title for <title> elements
|
|
27
|
+
* - Creates <meta> and <link> tags with a data-timber-head marker
|
|
28
|
+
* - Removes previous timber-managed tags to prevent accumulation
|
|
29
|
+
* - Replaces existing SSR-rendered tags with the same name/property
|
|
30
|
+
*/
|
|
31
|
+
export function applyHeadElements(elements: HeadElement[]): void {
|
|
32
|
+
// Remove previous timber-managed meta/link tags
|
|
33
|
+
document.head.querySelectorAll(`[${TIMBER_ATTR}]`).forEach((el) => el.remove());
|
|
34
|
+
|
|
35
|
+
for (const el of elements) {
|
|
36
|
+
if (el.tag === 'title' && el.content !== undefined) {
|
|
37
|
+
document.title = el.content;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (!el.attrs) continue;
|
|
42
|
+
|
|
43
|
+
// For meta: remove existing tag with same name/property to avoid duplicates from SSR
|
|
44
|
+
if (el.tag === 'meta') {
|
|
45
|
+
const key = el.attrs.name || el.attrs.property;
|
|
46
|
+
if (key) {
|
|
47
|
+
const existing = document.head.querySelector(
|
|
48
|
+
`meta[name="${key}"], meta[property="${key}"]`
|
|
49
|
+
);
|
|
50
|
+
if (existing) existing.remove();
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const node = document.createElement(el.tag);
|
|
55
|
+
node.setAttribute(TIMBER_ATTR, '');
|
|
56
|
+
for (const [k, v] of Object.entries(el.attrs)) {
|
|
57
|
+
node.setAttribute(k, v);
|
|
58
|
+
}
|
|
59
|
+
document.head.appendChild(node);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// History Stack — stores RSC payloads by URL for instant back/forward navigation
|
|
2
|
+
// See design/19-client-navigation.md § History Stack
|
|
3
|
+
|
|
4
|
+
import type { HeadElement } from './head';
|
|
5
|
+
|
|
6
|
+
// ─── Types ───────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
export interface HistoryEntry {
|
|
9
|
+
/** The complete segment tree payload at the time of navigation */
|
|
10
|
+
payload: unknown;
|
|
11
|
+
/** Resolved head elements for this page (title, meta tags). Null for SSR'd initial page. */
|
|
12
|
+
headElements?: HeadElement[] | null;
|
|
13
|
+
/** Route params for this page (for useParams). Null for SSR'd initial page. */
|
|
14
|
+
params?: Record<string, string | string[]> | null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// ─── History Stack ───────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Session-lived history stack keyed by URL. Enables instant back/forward
|
|
21
|
+
* navigation without a server roundtrip.
|
|
22
|
+
*
|
|
23
|
+
* On forward navigation, the new page's payload is pushed onto the stack.
|
|
24
|
+
* On popstate, the cached payload is replayed instantly.
|
|
25
|
+
*
|
|
26
|
+
* Scroll positions are stored in history.state (browser History API),
|
|
27
|
+
* not in this stack — see design/19-client-navigation.md §Scroll Restoration.
|
|
28
|
+
*
|
|
29
|
+
* Entries persist for the session duration (no expiry) and are cleared
|
|
30
|
+
* when the tab is closed — matching browser back-button behavior.
|
|
31
|
+
*/
|
|
32
|
+
export class HistoryStack {
|
|
33
|
+
private entries = new Map<string, HistoryEntry>();
|
|
34
|
+
|
|
35
|
+
push(url: string, entry: HistoryEntry): void {
|
|
36
|
+
this.entries.set(url, entry);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
get(url: string): HistoryEntry | undefined {
|
|
40
|
+
return this.entries.get(url);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
has(url: string): boolean {
|
|
44
|
+
return this.entries.has(url);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// @timber/app/client — Client-side primitives
|
|
2
|
+
// These are the primary imports for client components.
|
|
3
|
+
|
|
4
|
+
export type { RenderErrorDigest } from './types';
|
|
5
|
+
|
|
6
|
+
// Navigation
|
|
7
|
+
export { Link, interpolateParams, resolveHref, validateLinkHref, buildLinkProps } from './link';
|
|
8
|
+
export type { LinkProps, LinkPropsWithHref, LinkPropsWithParams } from './link';
|
|
9
|
+
export type { OnNavigateHandler, OnNavigateEvent } from './link-navigate-interceptor';
|
|
10
|
+
export { createRouter } from './router';
|
|
11
|
+
export type {
|
|
12
|
+
RouterInstance,
|
|
13
|
+
NavigationOptions,
|
|
14
|
+
RouterDeps,
|
|
15
|
+
RscDecoder,
|
|
16
|
+
RootRenderer,
|
|
17
|
+
} from './router';
|
|
18
|
+
export { useNavigationPending } from './use-navigation-pending';
|
|
19
|
+
export { useLinkStatus, LinkStatusContext } from './use-link-status';
|
|
20
|
+
export type { LinkStatus } from './use-link-status';
|
|
21
|
+
export { getRouter } from './router-ref';
|
|
22
|
+
export { useRouter } from './use-router';
|
|
23
|
+
export type { AppRouterInstance } from './use-router';
|
|
24
|
+
export { usePathname } from './use-pathname';
|
|
25
|
+
export { useSearchParams } from './use-search-params';
|
|
26
|
+
export { useSelectedLayoutSegment, useSelectedLayoutSegments } from './use-selected-layout-segment';
|
|
27
|
+
|
|
28
|
+
// Segment context (internal, used by rsc-entry to inject layout position)
|
|
29
|
+
export { SegmentProvider, useSegmentContext } from './segment-context';
|
|
30
|
+
export type { SegmentContextValue } from './segment-context';
|
|
31
|
+
|
|
32
|
+
// Segment cache (internal, but exported for advanced use)
|
|
33
|
+
export { SegmentCache, PrefetchCache } from './segment-cache';
|
|
34
|
+
export type { SegmentNode, StateTree } from './segment-cache';
|
|
35
|
+
|
|
36
|
+
// History (internal, but exported for advanced use)
|
|
37
|
+
export { HistoryStack } from './history';
|
|
38
|
+
export type { HistoryEntry } from './history';
|
|
39
|
+
|
|
40
|
+
// Forms
|
|
41
|
+
export { useActionState, useFormAction, useFormErrors } from './form';
|
|
42
|
+
export type { UseActionStateFn, UseActionStateReturn, FormErrorsResult } from './form';
|
|
43
|
+
|
|
44
|
+
// Params
|
|
45
|
+
export { useParams, setCurrentParams } from './use-params';
|
|
46
|
+
|
|
47
|
+
// Query states (URL-synced search params)
|
|
48
|
+
export { useQueryStates, bindUseQueryStates } from './use-query-states';
|
|
49
|
+
|
|
50
|
+
// Cookies
|
|
51
|
+
export { useCookie } from './use-cookie';
|
|
52
|
+
export type { ClientCookieOptions, CookieSetter } from './use-cookie';
|
|
53
|
+
|
|
54
|
+
// SSR data (framework-internal, used by ssr-entry to provide request data to hooks)
|
|
55
|
+
export { setSsrData, clearSsrData, getSsrData } from './ssr-data';
|
|
56
|
+
export type { SsrData } from './ssr-data';
|
|
57
|
+
|
|
58
|
+
// Error boundary (framework-internal, used by tree-builder and rsc-entry)
|
|
59
|
+
export { TimberErrorBoundary } from './error-boundary';
|
|
60
|
+
export type { TimberErrorBoundaryProps } from './error-boundary';
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
// LinkNavigateInterceptor — client component that stores an onNavigate callback
|
|
4
|
+
// on the parent <a> element so the delegated click handler in browser-entry.ts
|
|
5
|
+
// can invoke it before triggering SPA navigation.
|
|
6
|
+
//
|
|
7
|
+
// See design/19-client-navigation.md, TIM-167
|
|
8
|
+
|
|
9
|
+
import { useRef, useEffect, type ReactNode } from 'react';
|
|
10
|
+
|
|
11
|
+
/** Symbol used to store the onNavigate callback on anchor elements. */
|
|
12
|
+
export const ON_NAVIGATE_KEY = '__timberOnNavigate' as const;
|
|
13
|
+
|
|
14
|
+
export type OnNavigateEvent = {
|
|
15
|
+
preventDefault: () => void;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type OnNavigateHandler = (e: OnNavigateEvent) => void;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Augment HTMLAnchorElement with the optional onNavigate property.
|
|
22
|
+
* Used by browser-entry.ts handleLinkClick to check for the callback.
|
|
23
|
+
*/
|
|
24
|
+
declare global {
|
|
25
|
+
interface HTMLAnchorElement {
|
|
26
|
+
[ON_NAVIGATE_KEY]?: OnNavigateHandler;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Client component rendered inside <Link> that attaches the onNavigate
|
|
32
|
+
* callback to the closest <a> ancestor via a DOM property. The callback
|
|
33
|
+
* is cleaned up on unmount.
|
|
34
|
+
*
|
|
35
|
+
* Renders no extra DOM — just a transparent wrapper.
|
|
36
|
+
*/
|
|
37
|
+
export function LinkNavigateInterceptor({
|
|
38
|
+
onNavigate,
|
|
39
|
+
children,
|
|
40
|
+
}: {
|
|
41
|
+
onNavigate: OnNavigateHandler;
|
|
42
|
+
children: ReactNode;
|
|
43
|
+
}) {
|
|
44
|
+
const ref = useRef<HTMLSpanElement>(null);
|
|
45
|
+
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
const anchor = ref.current?.closest('a');
|
|
48
|
+
if (!anchor) return;
|
|
49
|
+
anchor[ON_NAVIGATE_KEY] = onNavigate;
|
|
50
|
+
return () => {
|
|
51
|
+
delete anchor[ON_NAVIGATE_KEY];
|
|
52
|
+
};
|
|
53
|
+
}, [onNavigate]);
|
|
54
|
+
|
|
55
|
+
// Use a <span> with display:contents to avoid affecting layout.
|
|
56
|
+
// The ref lets us walk up to the parent <a> in the effect.
|
|
57
|
+
return (
|
|
58
|
+
<span ref={ref} style={{ display: 'contents' }}>
|
|
59
|
+
{children}
|
|
60
|
+
</span>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
// LinkStatusProvider — client component that provides per-link pending status
|
|
4
|
+
// via React context. Used inside <Link> to power useLinkStatus().
|
|
5
|
+
|
|
6
|
+
import { useSyncExternalStore, type ReactNode } from 'react';
|
|
7
|
+
import { LinkStatusContext, type LinkStatus } from './use-link-status.js';
|
|
8
|
+
import { getRouter } from './router-ref.js';
|
|
9
|
+
|
|
10
|
+
const NOT_PENDING: LinkStatus = { pending: false };
|
|
11
|
+
const IS_PENDING: LinkStatus = { pending: true };
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Client component that subscribes to the router's pending URL and provides
|
|
15
|
+
* a scoped LinkStatusContext to children. Renders no extra DOM — just a
|
|
16
|
+
* context provider around children.
|
|
17
|
+
*/
|
|
18
|
+
export function LinkStatusProvider({ href, children }: { href: string; children: ReactNode }) {
|
|
19
|
+
const status = useSyncExternalStore(
|
|
20
|
+
(callback) => {
|
|
21
|
+
try {
|
|
22
|
+
return getRouter().onPendingChange(callback);
|
|
23
|
+
} catch {
|
|
24
|
+
return () => {};
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
() => {
|
|
28
|
+
try {
|
|
29
|
+
const pendingUrl = getRouter().getPendingUrl();
|
|
30
|
+
if (pendingUrl === href) return IS_PENDING;
|
|
31
|
+
return NOT_PENDING;
|
|
32
|
+
} catch {
|
|
33
|
+
return NOT_PENDING;
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
() => NOT_PENDING
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
return <LinkStatusContext.Provider value={status}>{children}</LinkStatusContext.Provider>;
|
|
40
|
+
}
|