@timber-js/app 0.1.0 → 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.
Files changed (141) hide show
  1. package/dist/index.js +5 -2
  2. package/dist/index.js.map +1 -1
  3. package/dist/plugins/entries.d.ts.map +1 -1
  4. package/package.json +43 -58
  5. package/src/adapters/cloudflare.ts +325 -0
  6. package/src/adapters/nitro.ts +366 -0
  7. package/src/adapters/types.ts +63 -0
  8. package/src/cache/index.ts +91 -0
  9. package/src/cache/redis-handler.ts +91 -0
  10. package/src/cache/register-cached-function.ts +99 -0
  11. package/src/cache/singleflight.ts +26 -0
  12. package/src/cache/stable-stringify.ts +21 -0
  13. package/src/cache/timber-cache.ts +116 -0
  14. package/src/cli.ts +201 -0
  15. package/src/client/browser-entry.ts +663 -0
  16. package/src/client/error-boundary.tsx +209 -0
  17. package/src/client/form.tsx +200 -0
  18. package/src/client/head.ts +61 -0
  19. package/src/client/history.ts +46 -0
  20. package/src/client/index.ts +60 -0
  21. package/src/client/link-navigate-interceptor.tsx +62 -0
  22. package/src/client/link-status-provider.tsx +40 -0
  23. package/src/client/link.tsx +310 -0
  24. package/src/client/nuqs-adapter.tsx +117 -0
  25. package/src/client/router-ref.ts +25 -0
  26. package/src/client/router.ts +563 -0
  27. package/src/client/segment-cache.ts +194 -0
  28. package/src/client/segment-context.ts +57 -0
  29. package/src/client/ssr-data.ts +95 -0
  30. package/src/client/types.ts +4 -0
  31. package/src/client/unload-guard.ts +34 -0
  32. package/src/client/use-cookie.ts +122 -0
  33. package/src/client/use-link-status.ts +46 -0
  34. package/src/client/use-navigation-pending.ts +47 -0
  35. package/src/client/use-params.ts +71 -0
  36. package/src/client/use-pathname.ts +43 -0
  37. package/src/client/use-query-states.ts +133 -0
  38. package/src/client/use-router.ts +77 -0
  39. package/src/client/use-search-params.ts +74 -0
  40. package/src/client/use-selected-layout-segment.ts +110 -0
  41. package/src/content/index.ts +13 -0
  42. package/src/cookies/define-cookie.ts +137 -0
  43. package/src/cookies/index.ts +9 -0
  44. package/src/fonts/ast.ts +359 -0
  45. package/src/fonts/css.ts +68 -0
  46. package/src/fonts/fallbacks.ts +248 -0
  47. package/src/fonts/google.ts +332 -0
  48. package/src/fonts/local.ts +177 -0
  49. package/src/fonts/types.ts +88 -0
  50. package/src/index.ts +413 -0
  51. package/src/plugins/adapter-build.ts +118 -0
  52. package/src/plugins/build-manifest.ts +323 -0
  53. package/src/plugins/build-report.ts +353 -0
  54. package/src/plugins/cache-transform.ts +199 -0
  55. package/src/plugins/chunks.ts +90 -0
  56. package/src/plugins/content.ts +136 -0
  57. package/src/plugins/dev-error-overlay.ts +230 -0
  58. package/src/plugins/dev-logs.ts +280 -0
  59. package/src/plugins/dev-server.ts +389 -0
  60. package/src/plugins/dynamic-transform.ts +161 -0
  61. package/src/plugins/entries.ts +207 -0
  62. package/src/plugins/fonts.ts +581 -0
  63. package/src/plugins/mdx.ts +179 -0
  64. package/src/plugins/react-prod.ts +56 -0
  65. package/src/plugins/routing.ts +419 -0
  66. package/src/plugins/server-action-exports.ts +220 -0
  67. package/src/plugins/server-bundle.ts +113 -0
  68. package/src/plugins/shims.ts +168 -0
  69. package/src/plugins/static-build.ts +207 -0
  70. package/src/routing/codegen.ts +396 -0
  71. package/src/routing/index.ts +14 -0
  72. package/src/routing/interception.ts +173 -0
  73. package/src/routing/scanner.ts +487 -0
  74. package/src/routing/status-file-lint.ts +114 -0
  75. package/src/routing/types.ts +100 -0
  76. package/src/search-params/analyze.ts +192 -0
  77. package/src/search-params/codecs.ts +153 -0
  78. package/src/search-params/create.ts +314 -0
  79. package/src/search-params/index.ts +23 -0
  80. package/src/search-params/registry.ts +31 -0
  81. package/src/server/access-gate.tsx +142 -0
  82. package/src/server/action-client.ts +473 -0
  83. package/src/server/action-handler.ts +325 -0
  84. package/src/server/actions.ts +236 -0
  85. package/src/server/asset-headers.ts +81 -0
  86. package/src/server/body-limits.ts +102 -0
  87. package/src/server/build-manifest.ts +234 -0
  88. package/src/server/canonicalize.ts +90 -0
  89. package/src/server/client-module-map.ts +58 -0
  90. package/src/server/csrf.ts +79 -0
  91. package/src/server/deny-renderer.ts +302 -0
  92. package/src/server/dev-logger.ts +419 -0
  93. package/src/server/dev-span-processor.ts +78 -0
  94. package/src/server/dev-warnings.ts +282 -0
  95. package/src/server/early-hints-sender.ts +55 -0
  96. package/src/server/early-hints.ts +142 -0
  97. package/src/server/error-boundary-wrapper.ts +69 -0
  98. package/src/server/error-formatter.ts +184 -0
  99. package/src/server/flush.ts +182 -0
  100. package/src/server/form-data.ts +176 -0
  101. package/src/server/form-flash.ts +93 -0
  102. package/src/server/html-injectors.ts +445 -0
  103. package/src/server/index.ts +222 -0
  104. package/src/server/instrumentation.ts +136 -0
  105. package/src/server/logger.ts +145 -0
  106. package/src/server/manifest-status-resolver.ts +215 -0
  107. package/src/server/metadata-render.ts +527 -0
  108. package/src/server/metadata-routes.ts +189 -0
  109. package/src/server/metadata.ts +263 -0
  110. package/src/server/middleware-runner.ts +32 -0
  111. package/src/server/nuqs-ssr-provider.tsx +63 -0
  112. package/src/server/pipeline.ts +555 -0
  113. package/src/server/prerender.ts +139 -0
  114. package/src/server/primitives.ts +264 -0
  115. package/src/server/proxy.ts +43 -0
  116. package/src/server/request-context.ts +554 -0
  117. package/src/server/route-element-builder.ts +395 -0
  118. package/src/server/route-handler.ts +153 -0
  119. package/src/server/route-matcher.ts +316 -0
  120. package/src/server/rsc-entry/api-handler.ts +112 -0
  121. package/src/server/rsc-entry/error-renderer.ts +177 -0
  122. package/src/server/rsc-entry/helpers.ts +147 -0
  123. package/src/server/rsc-entry/index.ts +688 -0
  124. package/src/server/rsc-entry/ssr-bridge.ts +18 -0
  125. package/src/server/slot-resolver.ts +359 -0
  126. package/src/server/ssr-entry.ts +161 -0
  127. package/src/server/ssr-render.ts +200 -0
  128. package/src/server/status-code-resolver.ts +282 -0
  129. package/src/server/tracing.ts +281 -0
  130. package/src/server/tree-builder.ts +354 -0
  131. package/src/server/types.ts +150 -0
  132. package/src/shims/font-google.ts +67 -0
  133. package/src/shims/headers.ts +11 -0
  134. package/src/shims/image.ts +48 -0
  135. package/src/shims/link.ts +9 -0
  136. package/src/shims/navigation-client.ts +52 -0
  137. package/src/shims/navigation.ts +31 -0
  138. package/src/shims/server-only-noop.js +5 -0
  139. package/src/utils/directive-parser.ts +529 -0
  140. package/src/utils/format.ts +10 -0
  141. 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
+ }