@timber-js/app 0.1.12 → 0.1.14

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.
@@ -1 +1 @@
1
- {"version":3,"file":"shims.d.ts","sourceRoot":"","sources":["../../src/plugins/shims.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,MAAM,CAAC;AAGnC,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AA6DhD;;;;GAIG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,aAAa,GAAG,MAAM,CAuGvD"}
1
+ {"version":3,"file":"shims.d.ts","sourceRoot":"","sources":["../../src/plugins/shims.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,MAAM,CAAC;AAGnC,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AA6DhD;;;;GAIG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,aAAa,GAAG,MAAM,CAkIvD"}
@@ -1 +1 @@
1
- {"version":3,"file":"slot-resolver.d.ts","sourceRoot":"","sources":["../../src/server/slot-resolver.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAMH,OAAO,KAAK,EAAE,mBAAmB,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAErE,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AAE9D,KAAK,eAAe,GAAG,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,KAAK,CAAC,YAAY,CAAC;AAElE;;;;;;;;;;GAUG;AACH,wBAAsB,kBAAkB,CACtC,QAAQ,EAAE,mBAAmB,EAC7B,KAAK,EAAE,UAAU,EACjB,aAAa,EAAE,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC,CAAC,EACzD,CAAC,EAAE,eAAe,EAClB,YAAY,CAAC,EAAE,mBAAmB,GACjC,OAAO,CAAC,KAAK,CAAC,YAAY,GAAG,IAAI,CAAC,CAiKpC"}
1
+ {"version":3,"file":"slot-resolver.d.ts","sourceRoot":"","sources":["../../src/server/slot-resolver.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAMH,OAAO,KAAK,EAAE,mBAAmB,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAErE,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AAE9D,KAAK,eAAe,GAAG,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,KAAK,CAAC,YAAY,CAAC;AAElE;;;;;;;;;;GAUG;AACH,wBAAsB,kBAAkB,CACtC,QAAQ,EAAE,mBAAmB,EAC7B,KAAK,EAAE,UAAU,EACjB,aAAa,EAAE,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC,CAAC,EACzD,CAAC,EAAE,eAAe,EAClB,YAAY,CAAC,EAAE,mBAAmB,GACjC,OAAO,CAAC,KAAK,CAAC,YAAY,GAAG,IAAI,CAAC,CAkKpC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timber-js/app",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
4
4
  "description": "Vite-native React framework for Cloudflare Workers — correct HTTP semantics, real status codes, pages that work without JavaScript",
5
5
  "keywords": [
6
6
  "cloudflare-workers",
@@ -71,6 +71,13 @@ export interface TimberErrorBoundaryProps {
71
71
  * 400 = any 4xx, 500 = any 5xx, specific number = exact match.
72
72
  */
73
73
  status?: number;
74
+ /**
75
+ * When true, catching a DenySignal sets _denyHandledByBoundary on the
76
+ * nav context to prevent page-level deny promotion. Only slot catch-all
77
+ * boundaries should set this — segment boundaries (403.tsx, 4xx.tsx,
78
+ * error.tsx) must NOT, otherwise normal page denies get swallowed.
79
+ */
80
+ isSlotBoundary?: boolean;
74
81
  children: ReactNode;
75
82
  }
76
83
 
@@ -99,26 +106,6 @@ export class TimberErrorBoundary extends Component<
99
106
  return { hasError: false, error: null };
100
107
  }
101
108
 
102
- // Report DenySignal handling to prevent page-level promotion.
103
- // When a slot's error boundary catches a DenySignal, the RSC onError
104
- // callback has already tracked it globally. Setting this flag tells
105
- // the RSC entry not to promote the denial to page-level (which would
106
- // replace the entire SSR response). See LOCAL-298.
107
- const digest = (error as { digest?: string }).digest;
108
- if (typeof digest === 'string') {
109
- try {
110
- const parsed = JSON.parse(digest);
111
- if (parsed?.type === 'deny') {
112
- const ssrData = getSsrData();
113
- if (ssrData?._navContext) {
114
- ssrData._navContext._denyHandledByBoundary = true;
115
- }
116
- }
117
- } catch {
118
- // Not a JSON digest — ignore
119
- }
120
- }
121
-
122
109
  return { hasError: true, error };
123
110
  }
124
111
 
@@ -161,6 +148,20 @@ export class TimberErrorBoundary extends Component<
161
148
  }
162
149
  }
163
150
 
151
+ // Report DenySignal handling to prevent page-level promotion — but only
152
+ // for slot boundaries. Segment boundaries (403.tsx, 4xx.tsx, error.tsx)
153
+ // must NOT set this flag, otherwise normal page/hold-window denies get
154
+ // swallowed as 200 with boundary HTML instead of the intended 4xx.
155
+ // Runs here in render() (not getDerivedStateFromError) so the status
156
+ // filter has already been applied — non-matching boundaries re-threw above.
157
+ // See LOCAL-298.
158
+ if (parsed?.type === 'deny' && this.props.isSlotBoundary) {
159
+ const ssrData = getSsrData();
160
+ if (ssrData?._navContext) {
161
+ ssrData._navContext._denyHandledByBoundary = true;
162
+ }
163
+ }
164
+
164
165
  // Render the fallback component with the right props shape.
165
166
  if (parsed?.type === 'deny') {
166
167
  return createElement(this.props.fallbackComponent as never, {
@@ -9,6 +9,7 @@
9
9
  * AppRouterInstance shape that ecosystem libraries expect.
10
10
  */
11
11
 
12
+ import { startTransition } from 'react';
12
13
  import { getRouter } from './router-ref.js';
13
14
 
14
15
  export interface AppRouterInstance {
@@ -56,13 +57,24 @@ export function useRouter(): AppRouterInstance {
56
57
 
57
58
  return {
58
59
  push(href: string, options?: { scroll?: boolean }) {
59
- void router.navigate(href, { scroll: options?.scroll });
60
+ // Wrap in startTransition so React 19 tracks the async navigation.
61
+ // React 19's startTransition accepts async callbacks — it keeps
62
+ // isPending=true until the returned promise resolves. This means
63
+ // useTransition's isPending reflects the full RSC fetch + render
64
+ // lifecycle when wrapping router.push() in startTransition.
65
+ startTransition(async () => {
66
+ await router.navigate(href, { scroll: options?.scroll });
67
+ });
60
68
  },
61
69
  replace(href: string, options?: { scroll?: boolean }) {
62
- void router.navigate(href, { scroll: options?.scroll, replace: true });
70
+ startTransition(async () => {
71
+ await router.navigate(href, { scroll: options?.scroll, replace: true });
72
+ });
63
73
  },
64
74
  refresh() {
65
- void router.refresh();
75
+ startTransition(async () => {
76
+ await router.refresh();
77
+ });
66
78
  },
67
79
  back() {
68
80
  window.history.back();
@@ -177,11 +177,38 @@ export function timberShims(_ctx: PluginContext): Plugin {
177
177
  // Server modules must never be bundled into the browser — if this
178
178
  // module is reached, there is a broken import chain that needs fixing.
179
179
  if (id === '\0timber:server-empty') {
180
- return `throw new Error(
181
- "[timber] @timber-js/app/server was imported from client code. " +
180
+ // Export named stubs instead of throwing at evaluation time.
181
+ // Throwing at eval breaks the module system — the browser can't
182
+ // resolve named imports like `import { notFound } from '...'`.
183
+ // Instead, each stub throws at call time with a clear message.
184
+ return `
185
+ const msg = "[timber] @timber-js/app/server was imported from client code. " +
182
186
  "Server modules (headers, cookies, redirect, deny, etc.) cannot be used in client components. " +
183
- "If you need these APIs, move the import to a server component or middleware."
184
- );`;
187
+ "If you need these APIs, move the import to a server component or middleware.";
188
+ function stub() { throw new Error(msg); }
189
+ export const headers = stub;
190
+ export const cookies = stub;
191
+ export const searchParams = stub;
192
+ export const deny = stub;
193
+ export const notFound = stub;
194
+ export const redirect = stub;
195
+ export const permanentRedirect = stub;
196
+ export const redirectExternal = stub;
197
+ export const waitUntil = stub;
198
+ export const RenderError = stub;
199
+ export const RedirectType = {};
200
+ export const DenySignal = stub;
201
+ export const RedirectSignal = stub;
202
+ export const createPipeline = stub;
203
+ export const revalidatePath = stub;
204
+ export const revalidateTag = stub;
205
+ export const createActionClient = stub;
206
+ export const ActionError = stub;
207
+ export const validated = stub;
208
+ export const getFormFlash = stub;
209
+ export const parseFormData = stub;
210
+ export const coerce = stub;
211
+ `;
185
212
  }
186
213
  },
187
214
  };
@@ -186,6 +186,7 @@ export async function resolveSlotElement(
186
186
  // See design/02-rendering-pipeline.md §"Slot Access Failure = Graceful Degradation"
187
187
  element = h(TimberErrorBoundary, {
188
188
  fallbackComponent: SlotErrorFallback,
189
+ isSlotBoundary: true,
189
190
  children: element,
190
191
  });
191
192
 
@@ -257,6 +258,12 @@ function findSlotMatch(slotNode: ManifestSegmentNode, match: RouteMatch): SlotMa
257
258
  if (slotNode.page) {
258
259
  return { page: slotNode.page, chain: [slotNode] };
259
260
  }
261
+ // Check for optional-catch-all child — [[...slug]] matches zero segments
262
+ for (const child of slotNode.children ?? []) {
263
+ if (child.segmentType === 'optional-catch-all' && child.page) {
264
+ return { page: child.page, chain: [slotNode, child] };
265
+ }
266
+ }
260
267
  return null;
261
268
  }
262
269
 
@@ -280,11 +287,27 @@ function findSlotMatch(slotNode: ManifestSegmentNode, match: RouteMatch): SlotMa
280
287
  // Try dynamic segments if no static match
281
288
  if (!found) {
282
289
  for (const child of directChildren) {
283
- if (child.segmentType === 'dynamic' || child.segmentType === 'catch-all') {
290
+ if (child.segmentType === 'dynamic') {
291
+ found = child;
292
+ break;
293
+ }
294
+ }
295
+ }
296
+
297
+ // Try catch-all segments — these consume ALL remaining URL segments,
298
+ // so we break out of the outer loop immediately.
299
+ if (!found) {
300
+ for (const child of directChildren) {
301
+ if (child.segmentType === 'catch-all' || child.segmentType === 'optional-catch-all') {
284
302
  found = child;
285
303
  break;
286
304
  }
287
305
  }
306
+ if (found) {
307
+ chain.push(found);
308
+ currentNode = found;
309
+ break;
310
+ }
288
311
  }
289
312
 
290
313
  // Try group children (transparent)