@timber-js/app 0.1.1 → 0.1.3
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.d.ts.map +1 -1
- package/dist/index.js +11 -7
- package/dist/index.js.map +1 -1
- package/dist/plugins/dev-server.d.ts.map +1 -1
- package/dist/plugins/entries.d.ts.map +1 -1
- package/package.json +5 -4
- 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 +420 -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 +391 -0
- package/src/plugins/dynamic-transform.ts +161 -0
- package/src/plugins/entries.ts +214 -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,194 @@
|
|
|
1
|
+
// Segment Cache — stores the mounted segment tree and prefetched payloads
|
|
2
|
+
// See design/19-client-navigation.md for architecture details.
|
|
3
|
+
|
|
4
|
+
import type { HeadElement } from './head';
|
|
5
|
+
|
|
6
|
+
// ─── Types ───────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
/** A prefetched RSC result with optional head elements and segment metadata. */
|
|
9
|
+
export interface PrefetchResult {
|
|
10
|
+
payload: unknown;
|
|
11
|
+
headElements: HeadElement[] | null;
|
|
12
|
+
/** Segment metadata from X-Timber-Segments header for populating the segment cache. */
|
|
13
|
+
segmentInfo?: SegmentInfo[] | null;
|
|
14
|
+
/** Route params from X-Timber-Params header for populating useParams(). */
|
|
15
|
+
params?: Record<string, string | string[]> | null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A node in the client-side segment tree. Each node represents a mounted
|
|
20
|
+
* layout or page segment with its RSC flight payload.
|
|
21
|
+
*/
|
|
22
|
+
export interface SegmentNode {
|
|
23
|
+
/** The segment's URL pattern (e.g., "/", "/dashboard", "/projects/[id]") */
|
|
24
|
+
segment: string;
|
|
25
|
+
/** The RSC flight payload for this segment (opaque to the cache) */
|
|
26
|
+
payload: unknown;
|
|
27
|
+
/** Whether the segment is async (async layouts always re-render on navigation) */
|
|
28
|
+
isAsync: boolean;
|
|
29
|
+
/** Child segments keyed by segment path */
|
|
30
|
+
children: Map<string, SegmentNode>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Serialized state tree sent via X-Timber-State-Tree header.
|
|
35
|
+
* Only sync segments are included — async segments always re-render.
|
|
36
|
+
*/
|
|
37
|
+
export interface StateTree {
|
|
38
|
+
segments: string[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ─── Segment Cache ───────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Maintains the client-side segment tree representing currently mounted
|
|
45
|
+
* layouts and pages. Used for navigation reconciliation — the router diffs
|
|
46
|
+
* new routes against this tree to determine which segments to re-fetch.
|
|
47
|
+
*/
|
|
48
|
+
export class SegmentCache {
|
|
49
|
+
private root: SegmentNode | undefined;
|
|
50
|
+
|
|
51
|
+
get(segment: string): SegmentNode | undefined {
|
|
52
|
+
if (segment === '/' || segment === this.root?.segment) {
|
|
53
|
+
return this.root;
|
|
54
|
+
}
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
set(segment: string, node: SegmentNode): void {
|
|
59
|
+
if (segment === '/' || !this.root) {
|
|
60
|
+
this.root = node;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
clear(): void {
|
|
65
|
+
this.root = undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Serialize the mounted segment tree for the X-Timber-State-Tree header.
|
|
70
|
+
* Only includes sync segments — async segments are excluded because the
|
|
71
|
+
* server must always re-render them (they may depend on request context).
|
|
72
|
+
*
|
|
73
|
+
* This is a performance optimization only, NOT a security boundary.
|
|
74
|
+
* The server always runs all access.ts files regardless of the state tree.
|
|
75
|
+
*/
|
|
76
|
+
serializeStateTree(): StateTree {
|
|
77
|
+
const segments: string[] = [];
|
|
78
|
+
if (this.root) {
|
|
79
|
+
collectSyncSegments(this.root, segments);
|
|
80
|
+
}
|
|
81
|
+
return { segments };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Recursively collect sync segment paths from the tree */
|
|
86
|
+
function collectSyncSegments(node: SegmentNode, out: string[]): void {
|
|
87
|
+
if (!node.isAsync) {
|
|
88
|
+
out.push(node.segment);
|
|
89
|
+
}
|
|
90
|
+
for (const child of node.children.values()) {
|
|
91
|
+
collectSyncSegments(child, out);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ─── Segment Tree Builder ────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Segment metadata from the server, sent via X-Timber-Segments header.
|
|
99
|
+
* Describes a rendered segment's path and whether it's async.
|
|
100
|
+
*/
|
|
101
|
+
export interface SegmentInfo {
|
|
102
|
+
path: string;
|
|
103
|
+
isAsync: boolean;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Build a SegmentNode tree from flat segment metadata.
|
|
108
|
+
*
|
|
109
|
+
* Takes an ordered list of segment descriptors (root → leaf) from the
|
|
110
|
+
* server's X-Timber-Segments header and constructs the hierarchical
|
|
111
|
+
* tree structure that SegmentCache expects.
|
|
112
|
+
*
|
|
113
|
+
* Each segment is nested as a child of the previous one, forming a
|
|
114
|
+
* linear chain from root to leaf. The leaf segment (page) is excluded
|
|
115
|
+
* from the tree — pages are never cached across navigations.
|
|
116
|
+
*/
|
|
117
|
+
export function buildSegmentTree(segments: SegmentInfo[]): SegmentNode | undefined {
|
|
118
|
+
// Need at least a root segment to build a tree
|
|
119
|
+
if (segments.length === 0) return undefined;
|
|
120
|
+
|
|
121
|
+
// Exclude the leaf (page) — pages always re-render on navigation.
|
|
122
|
+
// Only layouts are cached in the segment tree.
|
|
123
|
+
const layouts = segments.length > 1 ? segments.slice(0, -1) : segments;
|
|
124
|
+
|
|
125
|
+
let root: SegmentNode | undefined;
|
|
126
|
+
let parent: SegmentNode | undefined;
|
|
127
|
+
|
|
128
|
+
for (const info of layouts) {
|
|
129
|
+
const node: SegmentNode = {
|
|
130
|
+
segment: info.path,
|
|
131
|
+
payload: null,
|
|
132
|
+
isAsync: info.isAsync,
|
|
133
|
+
children: new Map(),
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
if (!root) {
|
|
137
|
+
root = node;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (parent) {
|
|
141
|
+
parent.children.set(info.path, node);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
parent = node;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return root;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ─── Prefetch Cache ──────────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
interface PrefetchEntry {
|
|
153
|
+
result: PrefetchResult;
|
|
154
|
+
expiresAt: number;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Short-lived cache for hover-triggered prefetches. Entries expire after
|
|
159
|
+
* 30 seconds. When a link is clicked, the prefetched payload is consumed
|
|
160
|
+
* (moved to the history stack) and removed from this cache.
|
|
161
|
+
*
|
|
162
|
+
* timber.js does NOT prefetch on viewport intersection — only explicit
|
|
163
|
+
* hover on <Link prefetch> triggers a prefetch.
|
|
164
|
+
*/
|
|
165
|
+
export class PrefetchCache {
|
|
166
|
+
private static readonly TTL_MS = 30_000;
|
|
167
|
+
private entries = new Map<string, PrefetchEntry>();
|
|
168
|
+
|
|
169
|
+
set(url: string, result: PrefetchResult): void {
|
|
170
|
+
this.entries.set(url, {
|
|
171
|
+
result,
|
|
172
|
+
expiresAt: Date.now() + PrefetchCache.TTL_MS,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
get(url: string): PrefetchResult | undefined {
|
|
177
|
+
const entry = this.entries.get(url);
|
|
178
|
+
if (!entry) return undefined;
|
|
179
|
+
if (Date.now() >= entry.expiresAt) {
|
|
180
|
+
this.entries.delete(url);
|
|
181
|
+
return undefined;
|
|
182
|
+
}
|
|
183
|
+
return entry.result;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Get and remove the entry (used when navigation consumes a prefetch) */
|
|
187
|
+
consume(url: string): PrefetchResult | undefined {
|
|
188
|
+
const result = this.get(url);
|
|
189
|
+
if (result !== undefined) {
|
|
190
|
+
this.entries.delete(url);
|
|
191
|
+
}
|
|
192
|
+
return result;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Segment Context — provides layout segment position for useSelectedLayoutSegment hooks.
|
|
3
|
+
*
|
|
4
|
+
* Each layout in the segment tree is wrapped with a SegmentProvider that stores
|
|
5
|
+
* the URL segments from root to the current layout level. The hooks read this
|
|
6
|
+
* context to determine which child segments are active below the calling layout.
|
|
7
|
+
*
|
|
8
|
+
* The context value is intentionally minimal: just the segment path array and
|
|
9
|
+
* parallel route keys. No internal cache details are exposed.
|
|
10
|
+
*
|
|
11
|
+
* Design docs: design/19-client-navigation.md, design/14-ecosystem.md
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
'use client';
|
|
15
|
+
|
|
16
|
+
import { createContext, useContext, createElement, useMemo } from 'react';
|
|
17
|
+
|
|
18
|
+
// ─── Types ───────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
export interface SegmentContextValue {
|
|
21
|
+
/** URL segments from root to this layout (e.g. ['', 'dashboard', 'settings']) */
|
|
22
|
+
segments: string[];
|
|
23
|
+
/** Parallel route slot keys available at this layout level (e.g. ['sidebar', 'modal']) */
|
|
24
|
+
parallelRouteKeys: string[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ─── Context ─────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
const SegmentContext = createContext<SegmentContextValue | null>(null);
|
|
30
|
+
|
|
31
|
+
/** Read the segment context. Returns null if no provider is above this component. */
|
|
32
|
+
export function useSegmentContext(): SegmentContextValue | null {
|
|
33
|
+
return useContext(SegmentContext);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ─── Provider ────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
interface SegmentProviderProps {
|
|
39
|
+
segments: string[];
|
|
40
|
+
parallelRouteKeys: string[];
|
|
41
|
+
children: React.ReactNode;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Wraps each layout to provide segment position context.
|
|
46
|
+
* Injected by rsc-entry.ts during element tree construction.
|
|
47
|
+
*/
|
|
48
|
+
export function SegmentProvider({ segments, parallelRouteKeys, children }: SegmentProviderProps) {
|
|
49
|
+
const value = useMemo(
|
|
50
|
+
() => ({ segments, parallelRouteKeys }),
|
|
51
|
+
// segments and parallelRouteKeys are static per layout — they don't change
|
|
52
|
+
// across navigations. The layout's position in the tree is fixed.
|
|
53
|
+
// Intentionally using derived keys — segments/parallelRouteKeys are static per layout
|
|
54
|
+
[segments.join('/'), parallelRouteKeys.join(',')]
|
|
55
|
+
);
|
|
56
|
+
return createElement(SegmentContext.Provider, { value }, children);
|
|
57
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSR Data — per-request state for client hooks during server-side rendering.
|
|
3
|
+
*
|
|
4
|
+
* RSC and SSR are separate Vite module graphs (see design/18-build-system.md),
|
|
5
|
+
* so the RSC environment's request-context ALS is not visible to SSR modules.
|
|
6
|
+
* This module provides getter/setter functions that ssr-entry.ts uses to
|
|
7
|
+
* populate per-request data for React's render.
|
|
8
|
+
*
|
|
9
|
+
* Request isolation: On the server, ssr-entry.ts registers an ALS-backed
|
|
10
|
+
* provider via registerSsrDataProvider(). getSsrData() reads from the ALS
|
|
11
|
+
* store, ensuring correct per-request data even when Suspense boundaries
|
|
12
|
+
* resolve asynchronously across concurrent requests. The module-level
|
|
13
|
+
* setSsrData/clearSsrData functions are kept as a fallback for tests
|
|
14
|
+
* and environments without ALS.
|
|
15
|
+
*
|
|
16
|
+
* IMPORTANT: This module must NOT import node:async_hooks or any Node.js-only
|
|
17
|
+
* APIs, as it's imported by 'use client' hooks that are bundled for the browser.
|
|
18
|
+
* The ALS instance lives in ssr-entry.ts (server-only); this module only holds
|
|
19
|
+
* a reference to the provider function.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
// ─── Types ────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
export interface SsrData {
|
|
25
|
+
/** The request's URL pathname (e.g. '/dashboard/settings') */
|
|
26
|
+
pathname: string;
|
|
27
|
+
/** The request's search params as a plain record */
|
|
28
|
+
searchParams: Record<string, string>;
|
|
29
|
+
/** The request's cookies as name→value pairs */
|
|
30
|
+
cookies: Map<string, string>;
|
|
31
|
+
/** The request's route params (e.g. { id: '123' }) */
|
|
32
|
+
params: Record<string, string | string[]>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ─── ALS-Backed Provider ─────────────────────────────────────────
|
|
36
|
+
//
|
|
37
|
+
// Server-side code (ssr-entry.ts) registers a provider that reads
|
|
38
|
+
// from AsyncLocalStorage. This avoids importing node:async_hooks
|
|
39
|
+
// in this browser-bundled module.
|
|
40
|
+
|
|
41
|
+
let _ssrDataProvider: (() => SsrData | undefined) | undefined;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Register an ALS-backed SSR data provider. Called once at module load
|
|
45
|
+
* by ssr-entry.ts to wire up per-request data via AsyncLocalStorage.
|
|
46
|
+
*
|
|
47
|
+
* When registered, getSsrData() reads from the provider (ALS store)
|
|
48
|
+
* instead of module-level state, ensuring correct isolation for
|
|
49
|
+
* concurrent requests with streaming Suspense.
|
|
50
|
+
*/
|
|
51
|
+
export function registerSsrDataProvider(provider: () => SsrData | undefined): void {
|
|
52
|
+
_ssrDataProvider = provider;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ─── Module-Level Fallback ────────────────────────────────────────
|
|
56
|
+
//
|
|
57
|
+
// Used by tests and as a fallback when no ALS provider is registered.
|
|
58
|
+
|
|
59
|
+
let currentSsrData: SsrData | undefined;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Set the SSR data for the current request via module-level state.
|
|
63
|
+
*
|
|
64
|
+
* In production, ssr-entry.ts uses ALS (runWithSsrData) instead.
|
|
65
|
+
* This function is retained for tests and as a fallback.
|
|
66
|
+
*/
|
|
67
|
+
export function setSsrData(data: SsrData): void {
|
|
68
|
+
currentSsrData = data;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Clear the SSR data after rendering completes.
|
|
73
|
+
*
|
|
74
|
+
* In production, ALS scope handles cleanup automatically.
|
|
75
|
+
* This function is retained for tests and as a fallback.
|
|
76
|
+
*/
|
|
77
|
+
export function clearSsrData(): void {
|
|
78
|
+
currentSsrData = undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Read the current request's SSR data. Returns undefined when called
|
|
83
|
+
* outside an SSR render (i.e. on the client after hydration).
|
|
84
|
+
*
|
|
85
|
+
* Prefers the ALS-backed provider when registered (server-side),
|
|
86
|
+
* falling back to module-level state (tests, legacy).
|
|
87
|
+
*
|
|
88
|
+
* Used by client hooks' server snapshot functions.
|
|
89
|
+
*/
|
|
90
|
+
export function getSsrData(): SsrData | undefined {
|
|
91
|
+
if (_ssrDataProvider) {
|
|
92
|
+
return _ssrDataProvider();
|
|
93
|
+
}
|
|
94
|
+
return currentSsrData;
|
|
95
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Page unload detection — suppresses spurious errors during page refresh/navigation.
|
|
3
|
+
*
|
|
4
|
+
* When the user refreshes the page or navigates away while React is still
|
|
5
|
+
* streaming Suspense content, the aborted connection causes streaming errors.
|
|
6
|
+
* These are not application errors — they're a side effect of the browser
|
|
7
|
+
* tearing down the connection. This module tracks whether the page is being
|
|
8
|
+
* unloaded so error boundaries and error handlers can suppress abort-related
|
|
9
|
+
* errors during the unload window.
|
|
10
|
+
*
|
|
11
|
+
* See design/10-error-handling.md §"Known limitation: deny() inside Suspense and hydration"
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
let unloading = false;
|
|
15
|
+
|
|
16
|
+
if (typeof window !== 'undefined') {
|
|
17
|
+
window.addEventListener('beforeunload', () => {
|
|
18
|
+
unloading = true;
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
// Also detect pagehide for bfcache-aware browsers (Safari).
|
|
22
|
+
// pagehide fires for both navigations and page hide events.
|
|
23
|
+
window.addEventListener('pagehide', () => {
|
|
24
|
+
unloading = true;
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Returns true if the page is currently being unloaded (user refreshed
|
|
30
|
+
* or navigated away). Error boundaries should suppress errors in this state.
|
|
31
|
+
*/
|
|
32
|
+
export function isPageUnloading(): boolean {
|
|
33
|
+
return unloading;
|
|
34
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useCookie — reactive client-side cookie hook.
|
|
3
|
+
*
|
|
4
|
+
* Uses useSyncExternalStore for SSR-safe, reactive cookie access.
|
|
5
|
+
* All components reading the same cookie name re-render on change.
|
|
6
|
+
* No cross-tab sync (intentional — see design/29-cookies.md).
|
|
7
|
+
*
|
|
8
|
+
* See design/29-cookies.md §"useCookie(name) Hook"
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { useSyncExternalStore } from 'react';
|
|
12
|
+
import { getSsrData } from './ssr-data.js';
|
|
13
|
+
|
|
14
|
+
// ─── Types ────────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
export interface ClientCookieOptions {
|
|
17
|
+
/** URL path scope. Default: '/'. */
|
|
18
|
+
path?: string;
|
|
19
|
+
/** Domain scope. Default: omitted (current domain). */
|
|
20
|
+
domain?: string;
|
|
21
|
+
/** Max age in seconds. */
|
|
22
|
+
maxAge?: number;
|
|
23
|
+
/** Expiration date. */
|
|
24
|
+
expires?: Date;
|
|
25
|
+
/** Cross-site policy. Default: 'lax'. */
|
|
26
|
+
sameSite?: 'strict' | 'lax' | 'none';
|
|
27
|
+
/** Only send over HTTPS. Default: true in production. */
|
|
28
|
+
secure?: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type CookieSetter = (value: string, options?: ClientCookieOptions) => void;
|
|
32
|
+
|
|
33
|
+
// ─── Module-Level Cookie Store ────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
type Listener = () => void;
|
|
36
|
+
|
|
37
|
+
/** Per-name subscriber sets. */
|
|
38
|
+
const listeners = new Map<string, Set<Listener>>();
|
|
39
|
+
|
|
40
|
+
/** Parse a cookie name from document.cookie. */
|
|
41
|
+
function getCookieValue(name: string): string | undefined {
|
|
42
|
+
if (typeof document === 'undefined') return undefined;
|
|
43
|
+
const match = document.cookie.match(
|
|
44
|
+
new RegExp('(?:^|;\\s*)' + name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\s*=\\s*([^;]*)')
|
|
45
|
+
);
|
|
46
|
+
return match ? decodeURIComponent(match[1]) : undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Serialize options into a cookie string suffix. */
|
|
50
|
+
function serializeOptions(options?: ClientCookieOptions): string {
|
|
51
|
+
if (!options) return '; Path=/; SameSite=Lax';
|
|
52
|
+
const parts: string[] = [];
|
|
53
|
+
parts.push(`Path=${options.path ?? '/'}`);
|
|
54
|
+
if (options.domain) parts.push(`Domain=${options.domain}`);
|
|
55
|
+
if (options.maxAge !== undefined) parts.push(`Max-Age=${options.maxAge}`);
|
|
56
|
+
if (options.expires) parts.push(`Expires=${options.expires.toUTCString()}`);
|
|
57
|
+
const sameSite = options.sameSite ?? 'lax';
|
|
58
|
+
parts.push(`SameSite=${sameSite.charAt(0).toUpperCase()}${sameSite.slice(1)}`);
|
|
59
|
+
if (options.secure) parts.push('Secure');
|
|
60
|
+
return '; ' + parts.join('; ');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Notify all subscribers for a given cookie name. */
|
|
64
|
+
function notify(name: string): void {
|
|
65
|
+
const subs = listeners.get(name);
|
|
66
|
+
if (subs) {
|
|
67
|
+
for (const fn of subs) fn();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ─── Hook ─────────────────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Reactive hook for reading/writing a client-side cookie.
|
|
75
|
+
*
|
|
76
|
+
* Returns `[value, setCookie, deleteCookie]`:
|
|
77
|
+
* - `value`: current cookie value (string | undefined)
|
|
78
|
+
* - `setCookie`: sets the cookie and triggers re-renders
|
|
79
|
+
* - `deleteCookie`: deletes the cookie and triggers re-renders
|
|
80
|
+
*
|
|
81
|
+
* @param name - Cookie name.
|
|
82
|
+
* @param defaultOptions - Default options for setCookie calls.
|
|
83
|
+
*/
|
|
84
|
+
export function useCookie(
|
|
85
|
+
name: string,
|
|
86
|
+
defaultOptions?: ClientCookieOptions
|
|
87
|
+
): [value: string | undefined, setCookie: CookieSetter, deleteCookie: () => void] {
|
|
88
|
+
const subscribe = (callback: Listener): (() => void) => {
|
|
89
|
+
let subs = listeners.get(name);
|
|
90
|
+
if (!subs) {
|
|
91
|
+
subs = new Set();
|
|
92
|
+
listeners.set(name, subs);
|
|
93
|
+
}
|
|
94
|
+
subs.add(callback);
|
|
95
|
+
return () => {
|
|
96
|
+
subs!.delete(callback);
|
|
97
|
+
if (subs!.size === 0) listeners.delete(name);
|
|
98
|
+
};
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const getSnapshot = (): string | undefined => getCookieValue(name);
|
|
102
|
+
const getServerSnapshot = (): string | undefined => getSsrData()?.cookies.get(name);
|
|
103
|
+
|
|
104
|
+
const value = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
|
105
|
+
|
|
106
|
+
const setCookie: CookieSetter = (newValue: string, options?: ClientCookieOptions) => {
|
|
107
|
+
const merged = { ...defaultOptions, ...options };
|
|
108
|
+
document.cookie = `${name}=${encodeURIComponent(newValue)}${serializeOptions(merged)}`;
|
|
109
|
+
notify(name);
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const deleteCookie = (): void => {
|
|
113
|
+
const path = defaultOptions?.path ?? '/';
|
|
114
|
+
const domain = defaultOptions?.domain;
|
|
115
|
+
let cookieStr = `${name}=; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Path=${path}`;
|
|
116
|
+
if (domain) cookieStr += `; Domain=${domain}`;
|
|
117
|
+
document.cookie = cookieStr;
|
|
118
|
+
notify(name);
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
return [value, setCookie, deleteCookie];
|
|
122
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
// useLinkStatus — returns { pending: true } while the nearest parent <Link>'s
|
|
4
|
+
// navigation is in flight. No arguments — scoped via React context.
|
|
5
|
+
// See design/19-client-navigation.md §"useLinkStatus()"
|
|
6
|
+
|
|
7
|
+
import { useContext, createContext } from 'react';
|
|
8
|
+
|
|
9
|
+
export interface LinkStatus {
|
|
10
|
+
pending: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* React context provided by <Link>. Holds the pending status
|
|
15
|
+
* for that specific link's navigation.
|
|
16
|
+
*/
|
|
17
|
+
export const LinkStatusContext = createContext<LinkStatus>({ pending: false });
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Returns `{ pending: true }` while the nearest parent `<Link>` component's
|
|
21
|
+
* navigation is in flight. Must be used inside a `<Link>` component's children.
|
|
22
|
+
*
|
|
23
|
+
* Unlike `useNavigationPending()` which is global, this hook is scoped to
|
|
24
|
+
* the nearest parent `<Link>` — only the link the user clicked shows pending.
|
|
25
|
+
*
|
|
26
|
+
* ```tsx
|
|
27
|
+
* 'use client'
|
|
28
|
+
* import { Link, useLinkStatus } from '@timber/app/client'
|
|
29
|
+
*
|
|
30
|
+
* function Hint() {
|
|
31
|
+
* const { pending } = useLinkStatus()
|
|
32
|
+
* return <span className={pending ? 'opacity-50' : ''} />
|
|
33
|
+
* }
|
|
34
|
+
*
|
|
35
|
+
* export function NavLink({ href, children }) {
|
|
36
|
+
* return (
|
|
37
|
+
* <Link href={href}>
|
|
38
|
+
* {children} <Hint />
|
|
39
|
+
* </Link>
|
|
40
|
+
* )
|
|
41
|
+
* }
|
|
42
|
+
* ```
|
|
43
|
+
*/
|
|
44
|
+
export function useLinkStatus(): LinkStatus {
|
|
45
|
+
return useContext(LinkStatusContext);
|
|
46
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// useNavigationPending — returns true while an RSC navigation is in flight.
|
|
2
|
+
// See design/19-client-navigation.md §"useNavigationPending()"
|
|
3
|
+
|
|
4
|
+
import { useSyncExternalStore } from 'react';
|
|
5
|
+
import { getRouter } from './router-ref.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Returns true while an RSC navigation is in flight.
|
|
9
|
+
*
|
|
10
|
+
* The pending state is true from the moment the RSC fetch starts until
|
|
11
|
+
* React reconciliation completes. This includes the fetch itself,
|
|
12
|
+
* RSC stream parsing, and React tree reconciliation.
|
|
13
|
+
*
|
|
14
|
+
* It does NOT include Suspense streaming after the shell — only the
|
|
15
|
+
* initial shell reconciliation.
|
|
16
|
+
*
|
|
17
|
+
* ```tsx
|
|
18
|
+
* 'use client'
|
|
19
|
+
* import { useNavigationPending } from '@timber/app/client'
|
|
20
|
+
*
|
|
21
|
+
* export function NavBar() {
|
|
22
|
+
* const isPending = useNavigationPending()
|
|
23
|
+
* return (
|
|
24
|
+
* <nav className={isPending ? 'opacity-50' : ''}>
|
|
25
|
+
* <Link href="/dashboard">Dashboard</Link>
|
|
26
|
+
* </nav>
|
|
27
|
+
* )
|
|
28
|
+
* }
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
export function useNavigationPending(): boolean {
|
|
32
|
+
return useSyncExternalStore(
|
|
33
|
+
(callback) => {
|
|
34
|
+
const router = getRouter();
|
|
35
|
+
return router.onPendingChange(callback);
|
|
36
|
+
},
|
|
37
|
+
() => {
|
|
38
|
+
try {
|
|
39
|
+
return getRouter().isPending();
|
|
40
|
+
} catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
// Server snapshot — always false during SSR
|
|
45
|
+
() => false
|
|
46
|
+
);
|
|
47
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useParams() — client-side hook for accessing route params.
|
|
3
|
+
*
|
|
4
|
+
* Returns the dynamic route parameters for the current URL.
|
|
5
|
+
* When called with a route pattern argument, TypeScript narrows
|
|
6
|
+
* the return type to the exact params shape for that route.
|
|
7
|
+
*
|
|
8
|
+
* Two layers of type narrowing work together:
|
|
9
|
+
* 1. The generic overload here uses the Routes interface directly —
|
|
10
|
+
* `useParams<R>()` returns `Routes[R]['params']`.
|
|
11
|
+
* 2. Build-time codegen generates per-route string-literal overloads
|
|
12
|
+
* in the .d.ts file for IDE autocomplete (see routing/codegen.ts).
|
|
13
|
+
*
|
|
14
|
+
* When the Routes interface is empty (no codegen yet), the generic
|
|
15
|
+
* overload has `keyof Routes = never`, so only the fallback matches.
|
|
16
|
+
*
|
|
17
|
+
* During SSR, params are read from the ALS-backed SSR data context
|
|
18
|
+
* (populated by ssr-entry.ts) to ensure correct per-request isolation
|
|
19
|
+
* across concurrent requests with streaming Suspense.
|
|
20
|
+
*
|
|
21
|
+
* Design doc: design/09-typescript.md §"Typed Routes"
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type { Routes } from '#/index.js';
|
|
25
|
+
import { getSsrData } from './ssr-data.js';
|
|
26
|
+
|
|
27
|
+
// The current params are set by the framework during navigation.
|
|
28
|
+
// In production, this is populated by the segment router when it
|
|
29
|
+
// processes an RSC payload and extracts the matched route params.
|
|
30
|
+
// During SSR, params are read from getSsrData() instead (ALS-backed).
|
|
31
|
+
let currentParams: Record<string, string | string[]> = {};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Set the current route params. Called by the framework internals
|
|
35
|
+
* during navigation — not intended for direct use by app code.
|
|
36
|
+
*
|
|
37
|
+
* On the client, the segment router calls this on each navigation.
|
|
38
|
+
* During SSR, params are also available via getSsrData().params
|
|
39
|
+
* (ALS-backed), but setCurrentParams is still called for the
|
|
40
|
+
* module-level fallback path.
|
|
41
|
+
*/
|
|
42
|
+
export function setCurrentParams(params: Record<string, string | string[]>): void {
|
|
43
|
+
currentParams = params;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Read the current route's dynamic params.
|
|
48
|
+
*
|
|
49
|
+
* The optional `_route` argument exists only for TypeScript narrowing —
|
|
50
|
+
* it does not affect the runtime return value.
|
|
51
|
+
*
|
|
52
|
+
* During SSR, reads from the ALS-backed SSR data context to ensure
|
|
53
|
+
* per-request isolation. On the client, reads from module-level state
|
|
54
|
+
* (set by the segment router on each navigation).
|
|
55
|
+
*
|
|
56
|
+
* @overload Typed — when a known route path is passed, returns the
|
|
57
|
+
* exact params shape from the generated Routes interface.
|
|
58
|
+
* @overload Fallback — returns the generic params record.
|
|
59
|
+
*/
|
|
60
|
+
export function useParams<R extends keyof Routes>(route: R): Routes[R]['params'];
|
|
61
|
+
export function useParams(route?: string): Record<string, string | string[]>;
|
|
62
|
+
export function useParams(_route?: string): Record<string, string | string[]> {
|
|
63
|
+
// During SSR, read from the ALS-backed SSR data context.
|
|
64
|
+
// This ensures correct params even for components inside Suspense
|
|
65
|
+
// boundaries that resolve asynchronously across concurrent requests.
|
|
66
|
+
const ssrData = getSsrData();
|
|
67
|
+
if (ssrData) {
|
|
68
|
+
return ssrData.params;
|
|
69
|
+
}
|
|
70
|
+
return currentParams;
|
|
71
|
+
}
|