@timber-js/app 0.1.29 → 0.1.31

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.
@@ -11,41 +11,61 @@
11
11
  * a transition update. React keeps the old committed tree visible while
12
12
  * any new Suspense boundaries in the transition resolve.
13
13
  *
14
- * This is the client-side equivalent of deferSuspenseFor on the server:
15
- * the old content stays visible until the new content is ready, avoiding
16
- * flash-of-fallback during fast navigations.
14
+ * Also manages `pendingUrl` via `useOptimistic`. During a navigation
15
+ * transition, the optimistic value (the target URL) shows immediately
16
+ * while the transition is pending, and automatically reverts to null
17
+ * when the transition commits. This ensures useLinkStatus and
18
+ * useNavigationPending show the pending state immediately and clear
19
+ * atomically with the new tree — same pattern Next.js uses with
20
+ * useOptimistic per Link instance, adapted for timber's server-component
21
+ * Link with global click delegation.
17
22
  *
18
23
  * See design/05-streaming.md §"deferSuspenseFor"
24
+ * See design/19-client-navigation.md §"NavigationContext"
19
25
  */
20
26
  import { type ReactNode } from 'react';
21
27
  /**
22
28
  * Root wrapper component that enables transition-based rendering.
23
29
  *
24
- * Renders no DOM elements returns the current element directly.
25
- * This means the DOM tree matches the server-rendered HTML during
26
- * hydration (TransitionRoot is invisible to the DOM).
30
+ * Renders PendingNavigationProvider around children for the pending URL
31
+ * context. The DOM tree matches the server-rendered HTML during hydration
32
+ * (the provider renders no extra DOM elements).
27
33
  *
28
34
  * Usage in browser-entry.ts:
29
35
  * const rootEl = createElement(TransitionRoot, { initial: wrapped });
30
36
  * reactRoot = hydrateRoot(document, rootEl);
31
37
  *
32
38
  * Subsequent navigations:
39
+ * navigateTransition(url, async () => { fetch; return wrappedElement; });
40
+ *
41
+ * Non-navigation renders:
33
42
  * transitionRender(newWrappedElement);
34
43
  */
35
44
  export declare function TransitionRoot({ initial }: {
36
45
  initial: ReactNode;
37
46
  }): ReactNode;
38
47
  /**
39
- * Trigger a transition render. React keeps the old committed tree
40
- * visible while any new Suspense boundaries in the update resolve.
41
- *
42
- * This is the function called by the router's renderRoot callback
43
- * instead of reactRoot.render() directly.
48
+ * Trigger a transition render for non-navigation updates.
49
+ * React keeps the old committed tree visible while any new Suspense
50
+ * boundaries in the update resolve.
44
51
  *
45
- * Falls back to no-op if TransitionRoot hasn't mounted yet (shouldn't
46
- * happen in practice — TransitionRoot mounts during hydration).
52
+ * Used for: applyRevalidation, popstate replay with cached payload.
47
53
  */
48
54
  export declare function transitionRender(element: ReactNode): void;
55
+ /**
56
+ * Run a full navigation inside a React transition with optimistic pending URL.
57
+ *
58
+ * The `perform` callback runs inside `startTransition` — it should fetch the
59
+ * RSC payload, update router state, and return the wrapped React element.
60
+ * The pending URL shows immediately (useOptimistic urgent update) and reverts
61
+ * to null when the transition commits (atomic with the new tree).
62
+ *
63
+ * Returns a Promise that resolves when the async work completes (note: the
64
+ * React transition may not have committed yet, but all state updates are done).
65
+ *
66
+ * Used for: navigate(), refresh(), popstate with fetch.
67
+ */
68
+ export declare function navigateTransition(pendingUrl: string, perform: () => Promise<ReactNode>): Promise<void>;
49
69
  /**
50
70
  * Check if the TransitionRoot is mounted and ready for renders.
51
71
  * Used by browser-entry.ts to guard against renders before hydration.
@@ -1 +1 @@
1
- {"version":3,"file":"transition-root.d.ts","sourceRoot":"","sources":["../../src/client/transition-root.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAO,EAA6B,KAAK,SAAS,EAAE,MAAM,OAAO,CAAC;AAalE;;;;;;;;;;;;;GAaG;AACH,wBAAgB,cAAc,CAAC,EAAE,OAAO,EAAE,EAAE;IAAE,OAAO,EAAE,SAAS,CAAA;CAAE,GAAG,SAAS,CAY7E;AAID;;;;;;;;;GASG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,SAAS,GAAG,IAAI,CAIzD;AAED;;;GAGG;AACH,wBAAgB,qBAAqB,IAAI,OAAO,CAE/C"}
1
+ {"version":3,"file":"transition-root.d.ts","sourceRoot":"","sources":["../../src/client/transition-root.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAEH,OAAO,EAKL,KAAK,SAAS,EACf,MAAM,OAAO,CAAC;AAuBf;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,cAAc,CAAC,EAAE,OAAO,EAAE,EAAE;IAAE,OAAO,EAAE,SAAS,CAAA;CAAE,GAAG,SAAS,CA8B7E;AAID;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,SAAS,GAAG,IAAI,CAIzD;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,kBAAkB,CAChC,UAAU,EAAE,MAAM,EAClB,OAAO,EAAE,MAAM,OAAO,CAAC,SAAS,CAAC,GAChC,OAAO,CAAC,IAAI,CAAC,CAMf;AAED;;;GAGG;AACH,wBAAgB,qBAAqB,IAAI,OAAO,CAE/C"}
@@ -1 +1 @@
1
- {"version":3,"file":"use-navigation-pending.d.ts","sourceRoot":"","sources":["../../src/client/use-navigation-pending.ts"],"names":[],"mappings":"AAMA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAgB,oBAAoB,IAAI,OAAO,CAgB9C"}
1
+ {"version":3,"file":"use-navigation-pending.d.ts","sourceRoot":"","sources":["../../src/client/use-navigation-pending.ts"],"names":[],"mappings":"AASA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAgB,oBAAoB,IAAI,OAAO,CAI9C"}
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { r as setViteServer, t as formatSize } from "./_chunks/format-DNt20Kt8.js";
2
2
  import { i as scanRoutes, n as generateRouteMap, t as collectInterceptionRewrites } from "./_chunks/interception-DGDIjDbR.js";
3
- import { existsSync, readFileSync } from "node:fs";
3
+ import { existsSync, readFileSync, statSync } from "node:fs";
4
4
  import { dirname, extname, join, resolve } from "node:path";
5
5
  import { fileURLToPath, pathToFileURL } from "node:url";
6
6
  import { createRequire } from "node:module";
@@ -12748,7 +12748,8 @@ function detectDynamicFontCallAst(source, importedNames) {
12748
12748
  } catch {
12749
12749
  return null;
12750
12750
  }
12751
- return walkForDynamicCalls(ast, new Set(importedNames), source);
12751
+ const nameSet = new Set(importedNames);
12752
+ return walkForDynamicCalls(ast, nameSet, source);
12752
12753
  }
12753
12754
  /**
12754
12755
  * Recursively walk the AST looking for CallExpression nodes where
@@ -14067,14 +14068,119 @@ function timberReactProd() {
14067
14068
  //#endregion
14068
14069
  //#region src/plugins/chunks.ts
14069
14070
  /**
14071
+ * timber-chunks — Vite sub-plugin for intelligent client chunk splitting.
14072
+ *
14073
+ * Splits client bundles into cache tiers based on update frequency:
14074
+ *
14075
+ * Tier 1: vendor-react — react, react-dom, scheduler (changes rarely)
14076
+ * Tier 2: vendor-timber — timber runtime, RSC runtime (changes per framework update)
14077
+ * Tier 3: vendor-app — user node_modules (changes on dependency updates)
14078
+ * Tier 4: shared-app — small shared app utilities/components (< 5KB source)
14079
+ * Tier 5: [route]-* — per-route page/layout chunks (default Rollup splitting)
14080
+ *
14081
+ * The shared-app tier prevents tiny utility modules (constants, helpers,
14082
+ * small UI components) from becoming individual chunks when shared across
14083
+ * routes. Without this, Rolldown creates per-module chunks for any code
14084
+ * shared between two or more entry points, producing many sub-1KB chunks.
14085
+ *
14086
+ * Server environments (RSC, SSR) are left to Vite's default chunking since
14087
+ * Cloudflare Workers load all code from a single deployment bundle with no
14088
+ * benefit from cache-tier separation.
14089
+ *
14090
+ * Design docs: 27-chunking-strategy.md
14091
+ */
14092
+ /**
14093
+ * Source file size threshold for the shared-app chunk.
14094
+ * Modules under this size that aren't route files get merged into shared-app
14095
+ * instead of getting their own tiny chunks.
14096
+ */
14097
+ var SMALL_MODULE_THRESHOLD = 5 * 1024;
14098
+ /**
14099
+ * Route convention file basenames (without extension).
14100
+ * These files define route segments and must stay in per-route chunks
14101
+ * to preserve route-based code splitting.
14102
+ */
14103
+ var ROUTE_FILE_BASENAMES = new Set([
14104
+ "page",
14105
+ "layout",
14106
+ "loading",
14107
+ "error",
14108
+ "not-found",
14109
+ "template",
14110
+ "access",
14111
+ "middleware",
14112
+ "default",
14113
+ "route"
14114
+ ]);
14115
+ /**
14116
+ * Cache for source file sizes to avoid repeated statSync calls.
14117
+ * Populated lazily during the build.
14118
+ */
14119
+ var sizeCache = /* @__PURE__ */ new Map();
14120
+ /**
14121
+ * Get the source file size, with caching.
14122
+ * Returns Infinity for virtual modules or files that can't be stat'd.
14123
+ */
14124
+ function getSourceSize(id) {
14125
+ const cached = sizeCache.get(id);
14126
+ if (cached !== void 0) return cached;
14127
+ try {
14128
+ const size = statSync(id).size;
14129
+ sizeCache.set(id, size);
14130
+ return size;
14131
+ } catch {
14132
+ sizeCache.set(id, Infinity);
14133
+ return Infinity;
14134
+ }
14135
+ }
14136
+ /**
14137
+ * Extract the basename without extension from a module ID.
14138
+ * e.g. '/project/app/dashboard/page.tsx' → 'page'
14139
+ */
14140
+ function getBasename(id) {
14141
+ const lastSlash = id.lastIndexOf("/");
14142
+ const filename = lastSlash >= 0 ? id.substring(lastSlash + 1) : id;
14143
+ const dotIndex = filename.indexOf(".");
14144
+ return dotIndex >= 0 ? filename.substring(0, dotIndex) : filename;
14145
+ }
14146
+ /**
14147
+ * Check if a module is a React ecosystem package (tier 1).
14148
+ */
14149
+ function isReactVendor(id) {
14150
+ return id.includes("node_modules/react-dom") || id.includes("node_modules/react/") || id.includes("node_modules/scheduler");
14151
+ }
14152
+ /**
14153
+ * Check if a module is part of the timber framework runtime (tier 2).
14154
+ */
14155
+ function isTimberRuntime(id) {
14156
+ return id.includes("/timber-app/") || id.includes("react-server-dom") || id.includes("@vitejs/plugin-rsc");
14157
+ }
14158
+ /**
14159
+ * Check if a module is a user-installed node_modules dependency (tier 3).
14160
+ * Excludes React ecosystem and timber runtime packages which have their own tiers.
14161
+ */
14162
+ function isUserVendor(id) {
14163
+ return id.includes("node_modules/") && !isReactVendor(id) && !isTimberRuntime(id);
14164
+ }
14165
+ /**
14166
+ * Check if a module is a route convention file that should stay per-route.
14167
+ */
14168
+ function isRouteFile(id) {
14169
+ return ROUTE_FILE_BASENAMES.has(getBasename(id));
14170
+ }
14171
+ /**
14070
14172
  * Categorize a module ID into a cache tier chunk name.
14071
14173
  *
14072
- * Returns a chunk name for vendor modules, or undefined to let
14073
- * Rollup's default splitting handle app/route code.
14174
+ * Returns a chunk name for vendor modules and small shared app code,
14175
+ * or undefined to let Rollup's default splitting handle route code.
14074
14176
  */
14075
14177
  function assignChunk(id) {
14076
- if (id.includes("node_modules/react-dom") || id.includes("node_modules/react/") || id.includes("node_modules/scheduler")) return "vendor-react";
14077
- if (id.includes("/timber-app/") || id.includes("react-server-dom") || id.includes("@vitejs/plugin-rsc")) return "vendor-timber";
14178
+ if (isReactVendor(id)) return "vendor-react";
14179
+ if (isTimberRuntime(id)) return "vendor-timber";
14180
+ if (isUserVendor(id)) return "vendor-app";
14181
+ if (!id.includes("\0") && id.startsWith("/") && !isRouteFile(id)) {
14182
+ if (getSourceSize(id) < SMALL_MODULE_THRESHOLD) return "shared-app";
14183
+ }
14078
14184
  }
14079
14185
  /**
14080
14186
  * Group timber's internal 'use client' modules into the vendor-timber chunk.
@@ -14082,10 +14188,17 @@ function assignChunk(id) {
14082
14188
  * The RSC plugin creates separate entry points for each 'use client' module,
14083
14189
  * which manualChunks can't merge. This function is passed as the RSC plugin's
14084
14190
  * `clientChunks` callback to group timber internals into a single chunk.
14085
- * User and third-party client components are left to default per-route splitting.
14191
+ *
14192
+ * User client components that are small (< 5KB) are grouped into shared-client
14193
+ * to prevent thin facade wrappers from becoming individual chunks. This handles
14194
+ * the RSC client reference facade problem where each 'use client' module gets
14195
+ * a ~100-300 byte re-export wrapper chunk.
14086
14196
  */
14087
14197
  function assignClientChunk(meta) {
14088
14198
  if (meta.id.includes("/timber-app/")) return "vendor-timber";
14199
+ if (!meta.id.includes("\0") && meta.id.startsWith("/")) {
14200
+ if (getSourceSize(meta.id) < SMALL_MODULE_THRESHOLD) return "shared-client";
14201
+ }
14089
14202
  }
14090
14203
  /**
14091
14204
  * Create the timber-chunks Vite plugin.