@timber-js/app 0.2.0-alpha.1 → 0.2.0-alpha.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.
@@ -12,8 +12,7 @@
12
12
  * 3. Setting up client-side navigation for subsequent page transitions
13
13
  *
14
14
  * After hydration, the browser entry:
15
- * - Intercepts clicks on <a data-timber-link> for SPA navigation
16
- * - Listens for mouseenter on <a data-timber-prefetch> for hover prefetch
15
+ * - Link click handling is per-component (Link's onClick), not global delegation
17
16
  * - Listens for popstate events for back/forward navigation
18
17
  *
19
18
  * Design docs: 18-build-system.md §"Entry Files", 19-client-navigation.md
@@ -1 +1 @@
1
- {"version":3,"file":"browser-entry.d.ts","sourceRoot":"","sources":["../../src/client/browser-entry.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG"}
1
+ {"version":3,"file":"browser-entry.d.ts","sourceRoot":"","sources":["../../src/client/browser-entry.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG"}
@@ -1,7 +1,7 @@
1
1
  export type { JsonSerializable, RenderErrorDigest } from './types';
2
2
  export { Link, interpolateParams, resolveHref, validateLinkHref, buildLinkProps } from './link';
3
3
  export type { LinkProps, LinkPropsWithHref, LinkPropsWithParams } from './link';
4
- export type { OnNavigateHandler, OnNavigateEvent } from './link-navigate-interceptor';
4
+ export type { OnNavigateHandler, OnNavigateEvent } from './link';
5
5
  export { createRouter } from './router';
6
6
  export type { RouterInstance, NavigationOptions, RouterDeps, RscDecoder, RootRenderer, } from './router';
7
7
  export { useNavigationPending } from './use-navigation-pending';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/client/index.ts"],"names":[],"mappings":"AAGA,YAAY,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,MAAM,SAAS,CAAC;AAGnE,OAAO,EAAE,IAAI,EAAE,iBAAiB,EAAE,WAAW,EAAE,gBAAgB,EAAE,cAAc,EAAE,MAAM,QAAQ,CAAC;AAChG,YAAY,EAAE,SAAS,EAAE,iBAAiB,EAAE,mBAAmB,EAAE,MAAM,QAAQ,CAAC;AAChF,YAAY,EAAE,iBAAiB,EAAE,eAAe,EAAE,MAAM,6BAA6B,CAAC;AACtF,OAAO,EAAE,YAAY,EAAE,MAAM,UAAU,CAAC;AACxC,YAAY,EACV,cAAc,EACd,iBAAiB,EACjB,UAAU,EACV,UAAU,EACV,YAAY,GACb,MAAM,UAAU,CAAC;AAClB,OAAO,EAAE,oBAAoB,EAAE,MAAM,0BAA0B,CAAC;AAChE,OAAO,EAAE,aAAa,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAC;AACrE,YAAY,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AACpD,OAAO,EAAE,SAAS,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC;AAC1D,OAAO,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AACzC,YAAY,EAAE,iBAAiB,EAAE,MAAM,cAAc,CAAC;AACtD,OAAO,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAC;AAC7C,OAAO,EAAE,eAAe,EAAE,MAAM,qBAAqB,CAAC;AACtD,OAAO,EAAE,wBAAwB,EAAE,yBAAyB,EAAE,MAAM,+BAA+B,CAAC;AAGpG,OAAO,EAAE,eAAe,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAC;AACvE,YAAY,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAC;AAG7D,OAAO,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAC9D,YAAY,EAAE,WAAW,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAG9D,OAAO,EAAE,YAAY,EAAE,MAAM,WAAW,CAAC;AACzC,YAAY,EAAE,YAAY,EAAE,MAAM,WAAW,CAAC;AAG9C,OAAO,EAAE,cAAc,EAAE,aAAa,EAAE,aAAa,EAAE,MAAM,QAAQ,CAAC;AACtE,YAAY,EAAE,gBAAgB,EAAE,oBAAoB,EAAE,gBAAgB,EAAE,MAAM,QAAQ,CAAC;AAGvF,OAAO,EAAE,SAAS,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAG3D,OAAO,EAAE,kBAAkB,EAAE,kBAAkB,EAAE,kBAAkB,EAAE,MAAM,sBAAsB,CAAC;AAClG,YAAY,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAC;AAG5D,OAAO,EAAE,cAAc,EAAE,kBAAkB,EAAE,MAAM,oBAAoB,CAAC;AAGxE,OAAO,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AACzC,YAAY,EAAE,mBAAmB,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAGtE,OAAO,EAAE,UAAU,EAAE,YAAY,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAClE,YAAY,EAAE,OAAO,EAAE,MAAM,YAAY,CAAC;AAG1C,OAAO,EAAE,mBAAmB,EAAE,MAAM,kBAAkB,CAAC;AACvD,YAAY,EAAE,wBAAwB,EAAE,MAAM,kBAAkB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/client/index.ts"],"names":[],"mappings":"AAGA,YAAY,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,MAAM,SAAS,CAAC;AAGnE,OAAO,EAAE,IAAI,EAAE,iBAAiB,EAAE,WAAW,EAAE,gBAAgB,EAAE,cAAc,EAAE,MAAM,QAAQ,CAAC;AAChG,YAAY,EAAE,SAAS,EAAE,iBAAiB,EAAE,mBAAmB,EAAE,MAAM,QAAQ,CAAC;AAChF,YAAY,EAAE,iBAAiB,EAAE,eAAe,EAAE,MAAM,QAAQ,CAAC;AACjE,OAAO,EAAE,YAAY,EAAE,MAAM,UAAU,CAAC;AACxC,YAAY,EACV,cAAc,EACd,iBAAiB,EACjB,UAAU,EACV,UAAU,EACV,YAAY,GACb,MAAM,UAAU,CAAC;AAClB,OAAO,EAAE,oBAAoB,EAAE,MAAM,0BAA0B,CAAC;AAChE,OAAO,EAAE,aAAa,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAC;AACrE,YAAY,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AACpD,OAAO,EAAE,SAAS,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC;AAC1D,OAAO,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AACzC,YAAY,EAAE,iBAAiB,EAAE,MAAM,cAAc,CAAC;AACtD,OAAO,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAC;AAC7C,OAAO,EAAE,eAAe,EAAE,MAAM,qBAAqB,CAAC;AACtD,OAAO,EAAE,wBAAwB,EAAE,yBAAyB,EAAE,MAAM,+BAA+B,CAAC;AAGpG,OAAO,EAAE,eAAe,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAC;AACvE,YAAY,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAC;AAG7D,OAAO,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAC9D,YAAY,EAAE,WAAW,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAG9D,OAAO,EAAE,YAAY,EAAE,MAAM,WAAW,CAAC;AACzC,YAAY,EAAE,YAAY,EAAE,MAAM,WAAW,CAAC;AAG9C,OAAO,EAAE,cAAc,EAAE,aAAa,EAAE,aAAa,EAAE,MAAM,QAAQ,CAAC;AACtE,YAAY,EAAE,gBAAgB,EAAE,oBAAoB,EAAE,gBAAgB,EAAE,MAAM,QAAQ,CAAC;AAGvF,OAAO,EAAE,SAAS,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAG3D,OAAO,EAAE,kBAAkB,EAAE,kBAAkB,EAAE,kBAAkB,EAAE,MAAM,sBAAsB,CAAC;AAClG,YAAY,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAC;AAG5D,OAAO,EAAE,cAAc,EAAE,kBAAkB,EAAE,MAAM,oBAAoB,CAAC;AAGxE,OAAO,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AACzC,YAAY,EAAE,mBAAmB,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAGtE,OAAO,EAAE,UAAU,EAAE,YAAY,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAClE,YAAY,EAAE,OAAO,EAAE,MAAM,YAAY,CAAC;AAG1C,OAAO,EAAE,mBAAmB,EAAE,MAAM,kBAAkB,CAAC;AACvD,YAAY,EAAE,wBAAwB,EAAE,MAAM,kBAAkB,CAAC"}
@@ -3,35 +3,8 @@ import { a as _setCurrentParams, c as cachedSearchParams, i as _setCachedSearch,
3
3
  import { n as useQueryStates, t as bindUseQueryStates } from "../_chunks/use-query-states-DAhgj8Gx.js";
4
4
  import { t as useCookie } from "../_chunks/use-cookie-dDbpCTx-.js";
5
5
  import { TimberErrorBoundary } from "./error-boundary.js";
6
- import React, { cloneElement, createContext, createElement, isValidElement, useActionState as useActionState$1, useContext, useEffect, useMemo, useRef, useSyncExternalStore, useTransition } from "react";
6
+ import React, { cloneElement, createContext, createElement, isValidElement, useActionState as useActionState$1, useContext, useMemo, useSyncExternalStore, useTransition } from "react";
7
7
  import { jsx } from "react/jsx-runtime";
8
- //#region src/client/link-navigate-interceptor.tsx
9
- /** Symbol used to store the onNavigate callback on anchor elements. */
10
- var ON_NAVIGATE_KEY = "__timberOnNavigate";
11
- /**
12
- * Client component rendered inside <Link> that attaches the onNavigate
13
- * callback to the closest <a> ancestor via a DOM property. The callback
14
- * is cleaned up on unmount.
15
- *
16
- * Renders no extra DOM — just a transparent wrapper.
17
- */
18
- function LinkNavigateInterceptor({ onNavigate, children }) {
19
- const ref = useRef(null);
20
- useEffect(() => {
21
- const anchor = ref.current?.closest("a");
22
- if (!anchor) return;
23
- anchor[ON_NAVIGATE_KEY] = onNavigate;
24
- return () => {
25
- delete anchor[ON_NAVIGATE_KEY];
26
- };
27
- }, [onNavigate]);
28
- return /* @__PURE__ */ jsx("span", {
29
- ref,
30
- style: { display: "contents" },
31
- children
32
- });
33
- }
34
- //#endregion
35
8
  //#region src/client/use-link-status.ts
36
9
  /**
37
10
  * React context provided by <Link>. Holds the pending status
@@ -93,9 +66,15 @@ function useLinkStatus() {
93
66
  * that depend on these APIs are safe to call from any environment —
94
67
  * they return null or no-op when the APIs aren't available.
95
68
  *
96
- * Singleton guarantee: With the simplified chunking strategy (LOCAL-337),
97
- * this module lives in exactly one client chunk. The previous globalThis +
98
- * Symbol.for workaround for cross-chunk duplication is no longer needed.
69
+ * SINGLETON GUARANTEE: All shared mutable state uses globalThis via
70
+ * Symbol.for keys. The RSC client bundler can duplicate this module
71
+ * across chunks (browser-entry graph + client-reference graph). With
72
+ * ESM output, each chunk gets its own module scope — module-level
73
+ * variables would create separate singleton instances per chunk.
74
+ * globalThis guarantees a single instance regardless of duplication.
75
+ *
76
+ * This workaround will be removed when Rolldown ships `format: 'app'`
77
+ * (module registry format that deduplicates like webpack/Turbopack).
99
78
  * See design/27-chunking-strategy.md.
100
79
  *
101
80
  * See design/19-client-navigation.md §"NavigationContext"
@@ -104,14 +83,25 @@ function useLinkStatus() {
104
83
  * The context is created lazily to avoid calling createContext at module
105
84
  * level. In the RSC environment, React.createContext doesn't exist —
106
85
  * calling it at import time would crash the server.
86
+ *
87
+ * Context instances are stored on globalThis (NOT in module-level
88
+ * variables) because the ESM bundler can duplicate this module across
89
+ * chunks. Module-level variables would create separate instances per
90
+ * chunk — the provider in TransitionRoot (index chunk) would use
91
+ * context A while the consumer in LinkStatusProvider (shared chunk)
92
+ * reads from context B. globalThis guarantees a single instance.
93
+ *
94
+ * See design/27-chunking-strategy.md §"Singleton Safety"
107
95
  */
108
- var _navCtx;
109
- var _pendingNavCtx;
96
+ var NAV_CTX_KEY = Symbol.for("__timber_nav_ctx");
97
+ var PENDING_CTX_KEY = Symbol.for("__timber_pending_nav_ctx");
110
98
  function getOrCreateContext() {
111
- if (_navCtx !== void 0) return _navCtx;
99
+ const existing = globalThis[NAV_CTX_KEY];
100
+ if (existing !== void 0) return existing;
112
101
  if (typeof React.createContext === "function") {
113
- _navCtx = React.createContext(null);
114
- return _navCtx;
102
+ const ctx = React.createContext(null);
103
+ globalThis[NAV_CTX_KEY] = ctx;
104
+ return ctx;
115
105
  }
116
106
  }
117
107
  /**
@@ -144,27 +134,42 @@ function NavigationProvider({ value, children }) {
144
134
  * NavigationProvider with the correct params/pathname.
145
135
  *
146
136
  * This is NOT used by hooks directly — hooks read from React context.
137
+ *
138
+ * Stored on globalThis (like the context instances above) because the
139
+ * router lives in one chunk while renderRoot lives in another. Module-
140
+ * level variables would be separate per chunk.
147
141
  */
148
- var _navState = {
149
- params: {},
150
- pathname: "/"
151
- };
142
+ var NAV_STATE_KEY = Symbol.for("__timber_nav_state");
143
+ function _getNavStateStore() {
144
+ const g = globalThis;
145
+ if (!g[NAV_STATE_KEY]) g[NAV_STATE_KEY] = { current: {
146
+ params: {},
147
+ pathname: "/"
148
+ } };
149
+ return g[NAV_STATE_KEY];
150
+ }
152
151
  function setNavigationState(state) {
153
- _navState = state;
152
+ _getNavStateStore().current = state;
154
153
  }
155
154
  function getNavigationState() {
156
- return _navState;
155
+ return _getNavStateStore().current;
157
156
  }
158
157
  /**
159
158
  * Separate context for the in-flight navigation URL. Provided by
160
159
  * TransitionRoot (urgent useState), consumed by LinkStatusProvider
161
160
  * and useNavigationPending.
161
+ *
162
+ * Uses globalThis via Symbol.for for the same reason as NavigationContext
163
+ * above — the bundler may duplicate this module across chunks, and module-
164
+ * level variables would create separate context instances.
162
165
  */
163
166
  function getOrCreatePendingContext() {
164
- if (_pendingNavCtx !== void 0) return _pendingNavCtx;
167
+ const existing = globalThis[PENDING_CTX_KEY];
168
+ if (existing !== void 0) return existing;
165
169
  if (typeof React.createContext === "function") {
166
- _pendingNavCtx = React.createContext(null);
167
- return _pendingNavCtx;
170
+ const ctx = React.createContext(null);
171
+ globalThis[PENDING_CTX_KEY] = ctx;
172
+ return ctx;
168
173
  }
169
174
  }
170
175
  /**
@@ -194,6 +199,30 @@ function LinkStatusProvider({ href, children }) {
194
199
  });
195
200
  }
196
201
  //#endregion
202
+ //#region src/client/router-ref.ts
203
+ /**
204
+ * Set the global router instance. Called once during bootstrap.
205
+ */
206
+ function setGlobalRouter(router) {
207
+ _setGlobalRouter(router);
208
+ }
209
+ /**
210
+ * Get the global router instance. Throws if called before bootstrap.
211
+ * Used by client-side hooks (useNavigationPending, etc.)
212
+ */
213
+ function getRouter() {
214
+ if (!globalRouter) throw new Error("[timber] Router not initialized. getRouter() was called before bootstrap().");
215
+ return globalRouter;
216
+ }
217
+ /**
218
+ * Get the global router instance or null if not yet initialized.
219
+ * Used by useRouter() methods to avoid silent failures — callers
220
+ * can log a meaningful warning instead of silently no-oping.
221
+ */
222
+ function getRouterOrNull() {
223
+ return globalRouter;
224
+ }
225
+ //#endregion
197
226
  //#region src/client/link.tsx
198
227
  /**
199
228
  * Reject dangerous URL schemes that could execute script.
@@ -263,44 +292,81 @@ function resolveHref(href, params, searchParams) {
263
292
  function buildLinkProps(props) {
264
293
  const resolvedHref = resolveHref(props.href, props.params, props.searchParams);
265
294
  validateLinkHref(resolvedHref);
266
- const output = { href: resolvedHref };
267
- if (isInternalHref(resolvedHref)) {
268
- output["data-timber-link"] = true;
269
- if (props.prefetch) output["data-timber-prefetch"] = true;
270
- if (props.scroll === false) output["data-timber-scroll"] = "false";
271
- }
272
- return output;
295
+ return { href: resolvedHref };
296
+ }
297
+ /**
298
+ * Should this click be intercepted for SPA navigation?
299
+ *
300
+ * Returns false (pass through to browser) when:
301
+ * - Modified keys are held (Ctrl, Meta, Shift, Alt) — open in new tab
302
+ * - The click is not the primary button
303
+ * - The event was already prevented by a parent handler
304
+ * - The link has target="_blank" or similar
305
+ * - The link has a download attribute
306
+ * - The href is external
307
+ */
308
+ function shouldInterceptClick(event, resolvedHref) {
309
+ if (event.button !== 0) return false;
310
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return false;
311
+ if (event.defaultPrevented) return false;
312
+ const anchor = event.currentTarget;
313
+ if (anchor.target && anchor.target !== "_self") return false;
314
+ if (anchor.hasAttribute("download")) return false;
315
+ if (!isInternalHref(resolvedHref)) return false;
316
+ return true;
273
317
  }
274
318
  /**
275
319
  * Navigation link with progressive enhancement.
276
320
  *
277
321
  * Renders as a plain `<a>` tag — works without JavaScript. When the client
278
- * runtime is active, it intercepts clicks on links marked with
279
- * `data-timber-link` to perform RSC-based client navigation.
322
+ * runtime is active, the Link's onClick handler triggers RSC-based client
323
+ * navigation via the router. No global event delegation — each Link owns
324
+ * its own click handling.
280
325
  *
281
326
  * Supports typed routes via codegen overloads. At runtime:
282
327
  * - `params` prop interpolates dynamic segments in the href pattern
283
328
  * - `searchParams` prop serializes query parameters via a SearchParamsDefinition
284
329
  */
285
- function Link({ href, prefetch, scroll, params, searchParams, onNavigate, children, ...rest }) {
286
- const linkProps = buildLinkProps({
330
+ function Link({ href, prefetch, scroll, params, searchParams, onNavigate, onClick: userOnClick, onMouseEnter: userOnMouseEnter, children, ...rest }) {
331
+ const { href: resolvedHref } = buildLinkProps({
287
332
  href,
288
- prefetch,
289
- scroll,
290
333
  params,
291
334
  searchParams
292
335
  });
293
- const inner = /* @__PURE__ */ jsx(LinkStatusProvider, {
294
- href: linkProps.href,
295
- children
296
- });
336
+ const internal = isInternalHref(resolvedHref);
337
+ const handleClick = internal ? (event) => {
338
+ userOnClick?.(event);
339
+ if (!shouldInterceptClick(event, resolvedHref)) return;
340
+ if (onNavigate) {
341
+ let prevented = false;
342
+ onNavigate({ preventDefault: () => {
343
+ prevented = true;
344
+ } });
345
+ if (prevented) {
346
+ event.preventDefault();
347
+ return;
348
+ }
349
+ }
350
+ const router = getRouterOrNull();
351
+ if (!router) return;
352
+ event.preventDefault();
353
+ const shouldScroll = scroll !== false;
354
+ router.navigate(resolvedHref, { scroll: shouldScroll });
355
+ } : userOnClick;
356
+ const handleMouseEnter = internal && prefetch ? (event) => {
357
+ userOnMouseEnter?.(event);
358
+ const router = getRouterOrNull();
359
+ if (router) router.prefetch(resolvedHref);
360
+ } : userOnMouseEnter;
297
361
  return /* @__PURE__ */ jsx("a", {
298
362
  ...rest,
299
- ...linkProps,
300
- children: onNavigate ? /* @__PURE__ */ jsx(LinkNavigateInterceptor, {
301
- onNavigate,
302
- children: inner
303
- }) : inner
363
+ href: resolvedHref,
364
+ onClick: handleClick,
365
+ onMouseEnter: handleMouseEnter,
366
+ children: /* @__PURE__ */ jsx(LinkStatusProvider, {
367
+ href: resolvedHref,
368
+ children
369
+ })
304
370
  });
305
371
  }
306
372
  //#endregion
@@ -1105,30 +1171,6 @@ function useNavigationPending() {
1105
1171
  return usePendingNavigationUrl() !== null;
1106
1172
  }
1107
1173
  //#endregion
1108
- //#region src/client/router-ref.ts
1109
- /**
1110
- * Set the global router instance. Called once during bootstrap.
1111
- */
1112
- function setGlobalRouter(router) {
1113
- _setGlobalRouter(router);
1114
- }
1115
- /**
1116
- * Get the global router instance. Throws if called before bootstrap.
1117
- * Used by client-side hooks (useNavigationPending, etc.)
1118
- */
1119
- function getRouter() {
1120
- if (!globalRouter) throw new Error("[timber] Router not initialized. getRouter() was called before bootstrap().");
1121
- return globalRouter;
1122
- }
1123
- /**
1124
- * Get the global router instance or null if not yet initialized.
1125
- * Used by useRouter() methods to avoid silent failures — callers
1126
- * can log a meaningful warning instead of silently no-oping.
1127
- */
1128
- function getRouterOrNull() {
1129
- return globalRouter;
1130
- }
1131
- //#endregion
1132
1174
  //#region src/client/use-router.ts
1133
1175
  /**
1134
1176
  * useRouter() — client-side hook for programmatic navigation.