@timber-js/app 0.2.0-alpha.145 → 0.2.0-alpha.146

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 (149) hide show
  1. package/dist/_chunks/{actions-DzFMnUAR.js → actions-OIZ8siQu.js} +28 -43
  2. package/dist/_chunks/actions-OIZ8siQu.js.map +1 -0
  3. package/dist/_chunks/{handler-store-DjISKYEB.js → handler-store-BVDgyTh7.js} +26 -2
  4. package/dist/_chunks/handler-store-BVDgyTh7.js.map +1 -0
  5. package/dist/_chunks/{href-validation-EZktELp8.js → href-validation-DyZIzfMJ.js} +18 -2
  6. package/dist/_chunks/{href-validation-EZktELp8.js.map → href-validation-DyZIzfMJ.js.map} +1 -1
  7. package/dist/_chunks/{navigation-root-DLc4s4ZC.js → navigation-root-bUDZLw74.js} +23 -2
  8. package/dist/_chunks/navigation-root-bUDZLw74.js.map +1 -0
  9. package/dist/_chunks/tracing-M_zKubhk.js.map +1 -1
  10. package/dist/adapters/cloudflare.d.ts +1 -15
  11. package/dist/adapters/cloudflare.d.ts.map +1 -1
  12. package/dist/adapters/cloudflare.js +10 -28
  13. package/dist/adapters/cloudflare.js.map +1 -1
  14. package/dist/adapters/compress-module.d.ts.map +1 -1
  15. package/dist/adapters/nitro.d.ts.map +1 -1
  16. package/dist/adapters/nitro.js +18 -5
  17. package/dist/adapters/nitro.js.map +1 -1
  18. package/dist/cache/index.js +39 -12
  19. package/dist/cache/index.js.map +1 -1
  20. package/dist/cache/redis-handler.d.ts +72 -8
  21. package/dist/cache/redis-handler.d.ts.map +1 -1
  22. package/dist/cache/stable-stringify.d.ts +9 -0
  23. package/dist/cache/stable-stringify.d.ts.map +1 -1
  24. package/dist/cache/timber-cache.d.ts +3 -1
  25. package/dist/cache/timber-cache.d.ts.map +1 -1
  26. package/dist/client/browser-entry/action-dispatch.d.ts.map +1 -1
  27. package/dist/client/browser-entry/router-init.d.ts.map +1 -1
  28. package/dist/client/browser-entry/scroll.d.ts +9 -0
  29. package/dist/client/browser-entry/scroll.d.ts.map +1 -1
  30. package/dist/client/error-boundary.d.ts.map +1 -1
  31. package/dist/client/error-boundary.js +11 -6
  32. package/dist/client/error-boundary.js.map +1 -1
  33. package/dist/client/index.js +3 -3
  34. package/dist/client/index.js.map +1 -1
  35. package/dist/client/internal.js +99 -24
  36. package/dist/client/internal.js.map +1 -1
  37. package/dist/client/link.d.ts.map +1 -1
  38. package/dist/client/navigation-api.d.ts.map +1 -1
  39. package/dist/client/navigation-root.d.ts +13 -0
  40. package/dist/client/navigation-root.d.ts.map +1 -1
  41. package/dist/client/router.d.ts +7 -0
  42. package/dist/client/router.d.ts.map +1 -1
  43. package/dist/client/rsc-fetch.d.ts.map +1 -1
  44. package/dist/dev-tools/404-page.d.ts +6 -2
  45. package/dist/dev-tools/404-page.d.ts.map +1 -1
  46. package/dist/dev-tools/dev-page-shell.d.ts +77 -0
  47. package/dist/dev-tools/dev-page-shell.d.ts.map +1 -0
  48. package/dist/dev-tools/error-page.d.ts +4 -30
  49. package/dist/dev-tools/error-page.d.ts.map +1 -1
  50. package/dist/dev-tools/index.d.ts +2 -1
  51. package/dist/dev-tools/index.d.ts.map +1 -1
  52. package/dist/dev-tools/instrumentation.d.ts +11 -1
  53. package/dist/dev-tools/instrumentation.d.ts.map +1 -1
  54. package/dist/index.d.ts.map +1 -1
  55. package/dist/index.js +430 -253
  56. package/dist/index.js.map +1 -1
  57. package/dist/plugins/cache.d.ts +43 -9
  58. package/dist/plugins/cache.d.ts.map +1 -1
  59. package/dist/plugins/cloudflare-wasm.d.ts +4 -1
  60. package/dist/plugins/cloudflare-wasm.d.ts.map +1 -1
  61. package/dist/plugins/dev-server.d.ts +16 -0
  62. package/dist/plugins/dev-server.d.ts.map +1 -1
  63. package/dist/plugins/server-bundle.d.ts.map +1 -1
  64. package/dist/plugins/shims.d.ts +23 -0
  65. package/dist/plugins/shims.d.ts.map +1 -1
  66. package/dist/server/action-handler.d.ts.map +1 -1
  67. package/dist/server/actions.d.ts +3 -2
  68. package/dist/server/actions.d.ts.map +1 -1
  69. package/dist/server/cookie-context.d.ts.map +1 -1
  70. package/dist/server/cookie-parsing.d.ts.map +1 -1
  71. package/dist/server/fallback-error.d.ts +2 -2
  72. package/dist/server/fallback-error.d.ts.map +1 -1
  73. package/dist/server/index.js +1 -1
  74. package/dist/server/internal.js +1 -1
  75. package/dist/server/internal.js.map +1 -1
  76. package/dist/server/node-stream-transforms.d.ts +24 -6
  77. package/dist/server/node-stream-transforms.d.ts.map +1 -1
  78. package/dist/server/pipeline.d.ts +11 -0
  79. package/dist/server/pipeline.d.ts.map +1 -1
  80. package/dist/server/primitives.d.ts +1 -11
  81. package/dist/server/primitives.d.ts.map +1 -1
  82. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  83. package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -1
  84. package/dist/server/slot-resolver.d.ts +6 -4
  85. package/dist/server/slot-resolver.d.ts.map +1 -1
  86. package/dist/server/ssr-entry.d.ts.map +1 -1
  87. package/dist/server/tracing.d.ts +2 -1
  88. package/dist/server/tracing.d.ts.map +1 -1
  89. package/dist/shared/head-header.d.ts +54 -0
  90. package/dist/shared/head-header.d.ts.map +1 -0
  91. package/dist/shared/href-validation.d.ts +11 -0
  92. package/dist/shared/href-validation.d.ts.map +1 -1
  93. package/dist/shared/redirect-type.d.ts +17 -0
  94. package/dist/shared/redirect-type.d.ts.map +1 -0
  95. package/dist/shims/headers.d.ts +52 -1
  96. package/dist/shims/headers.d.ts.map +1 -1
  97. package/dist/shims/navigation-client.d.ts +33 -11
  98. package/dist/shims/navigation-client.d.ts.map +1 -1
  99. package/package.json +10 -9
  100. package/src/adapters/cloudflare.ts +13 -47
  101. package/src/adapters/compress-module.ts +9 -3
  102. package/src/adapters/nitro.ts +9 -2
  103. package/src/cache/redis-handler.ts +77 -13
  104. package/src/cache/stable-stringify.ts +65 -2
  105. package/src/cache/timber-cache.ts +25 -13
  106. package/src/cli.ts +0 -0
  107. package/src/client/browser-entry/action-dispatch.ts +7 -2
  108. package/src/client/browser-entry/router-init.ts +2 -1
  109. package/src/client/browser-entry/scroll.ts +23 -0
  110. package/src/client/error-boundary.tsx +19 -5
  111. package/src/client/link.tsx +4 -1
  112. package/src/client/navigation-api.ts +5 -1
  113. package/src/client/navigation-root.tsx +16 -0
  114. package/src/client/router.ts +107 -29
  115. package/src/client/rsc-fetch.ts +6 -6
  116. package/src/dev-tools/404-page.ts +22 -123
  117. package/src/dev-tools/dev-page-shell.ts +256 -0
  118. package/src/dev-tools/error-page.ts +27 -203
  119. package/src/dev-tools/index.ts +7 -1
  120. package/src/dev-tools/instrumentation.ts +39 -2
  121. package/src/index.ts +1 -0
  122. package/src/plugins/cache.ts +79 -21
  123. package/src/plugins/cloudflare-wasm.ts +48 -10
  124. package/src/plugins/dev-server.ts +32 -8
  125. package/src/plugins/server-bundle.ts +25 -9
  126. package/src/plugins/shims.ts +94 -52
  127. package/src/server/action-handler.ts +10 -2
  128. package/src/server/actions.ts +3 -2
  129. package/src/server/compress.ts +11 -2
  130. package/src/server/cookie-context.ts +11 -0
  131. package/src/server/cookie-parsing.ts +16 -4
  132. package/src/server/fallback-error.ts +9 -4
  133. package/src/server/node-stream-transforms.ts +61 -24
  134. package/src/server/pipeline.ts +12 -0
  135. package/src/server/primitives.ts +10 -31
  136. package/src/server/rsc-entry/index.ts +7 -2
  137. package/src/server/rsc-entry/rsc-payload.ts +4 -3
  138. package/src/server/slot-resolver.ts +27 -14
  139. package/src/server/ssr-entry.ts +17 -28
  140. package/src/server/tracing.ts +17 -1
  141. package/src/shared/head-header.ts +69 -0
  142. package/src/shared/href-validation.ts +24 -0
  143. package/src/shared/redirect-type.ts +17 -0
  144. package/src/shims/headers.ts +119 -2
  145. package/src/shims/navigation-client.ts +48 -27
  146. package/LICENSE +0 -8
  147. package/dist/_chunks/actions-DzFMnUAR.js.map +0 -1
  148. package/dist/_chunks/handler-store-DjISKYEB.js.map +0 -1
  149. package/dist/_chunks/navigation-root-DLc4s4ZC.js.map +0 -1
@@ -471,7 +471,14 @@ const host = process.env.HOST || process.env.HOSTNAME || 'localhost';
471
471
  let boundPort = startPort;
472
472
 
473
473
  const server = createServer(async (req, res) => {
474
- const url = new URL(req.url || '/', \`http://\${host}:\${boundPort}\`);
474
+ // Scheme from the socket's TLS state — a hardcoded http:// breaks CSRF
475
+ // origin comparison and absolute-URL construction when TLS is terminated
476
+ // at this server (TIM-1073; mirrors the dev-server fix in TIM-1067).
477
+ // X-Forwarded-Proto is intentionally not consulted here: on a direct
478
+ // connection it would be attacker-controlled. The CSRF layer honors it
479
+ // separately for reverse-proxy deployments.
480
+ const scheme = req.socket?.encrypted ? 'https' : 'http';
481
+ const url = new URL(req.url || '/', \`\${scheme}://\${host}:\${boundPort}\`);
475
482
 
476
483
  // Try serving static files from the public directory first.
477
484
  const filePath = join(publicDir, url.pathname);
@@ -557,7 +564,7 @@ const server = createServer(async (req, res) => {
557
564
  // is silently dropped: defineCookie().setCookie() in actions, middleware
558
565
  // cookie writes, the framework's own session cookies, all of it. See
559
566
  // LOCAL-741.
560
- const responseHeaders: Record<string, string | string[]> = {};
567
+ const responseHeaders = {};
561
568
  webResponse.headers.forEach((value, key) => {
562
569
  if (key.toLowerCase() !== 'set-cookie') {
563
570
  responseHeaders[key] = value;
@@ -1,15 +1,78 @@
1
1
  import type { CacheHandler } from './index';
2
2
 
3
3
  /**
4
- * Minimal Redis client interface — compatible with ioredis, node-redis, and
5
- * Cloudflare Workers Redis bindings. We depend on the interface, not the
6
- * implementation, so users bring their own Redis client.
4
+ * Minimal Redis client contract for `RedisCacheHandler`.
5
+ *
6
+ * This is deliberately NOT the native shape of any client library. ioredis,
7
+ * node-redis v4, and @upstash/redis all disagree on TTL argument shapes
8
+ * (positional `'EX'` vs `{ EX }` vs `{ ex }`), method casing (`sadd` vs
9
+ * `sAdd`), and array handling in `del` — a contract that pattern-matches one
10
+ * client lets another silently drop the expiry (TIM-1030: node-redis ignored
11
+ * the positional `'EX'` and wrote keys with no TTL). Instead, TTL semantics
12
+ * are explicit in the signature and you adapt your client with a thin
13
+ * wrapper, so a mismatch is a type error rather than silent unbounded
14
+ * keyspace growth.
15
+ *
16
+ * @example ioredis
17
+ * ```ts
18
+ * import Redis from 'ioredis';
19
+ * const redis = new Redis(process.env.REDIS_URL);
20
+ * const client: RedisClient = {
21
+ * get: (k) => redis.get(k),
22
+ * set: (k, v, ttl) => redis.set(k, v, 'EX', ttl),
23
+ * expire: (k, s) => redis.expire(k, s),
24
+ * del: (k) => redis.del(k),
25
+ * sadd: (k, ...m) => redis.sadd(k, ...m),
26
+ * smembers: (k) => redis.smembers(k),
27
+ * };
28
+ * ```
29
+ *
30
+ * @example node-redis v4
31
+ * ```ts
32
+ * import { createClient } from 'redis';
33
+ * const redis = createClient({ url: process.env.REDIS_URL });
34
+ * await redis.connect();
35
+ * const client: RedisClient = {
36
+ * get: (k) => redis.get(k),
37
+ * set: (k, v, ttl) => redis.set(k, v, { EX: ttl }),
38
+ * expire: (k, s) => redis.expire(k, s),
39
+ * del: (k) => redis.del(k),
40
+ * sadd: (k, ...m) => redis.sAdd(k, m),
41
+ * smembers: (k) => redis.sMembers(k),
42
+ * };
43
+ * ```
44
+ *
45
+ * @example @upstash/redis
46
+ * ```ts
47
+ * import { Redis } from '@upstash/redis';
48
+ * // automaticDeserialization must be off: the handler stores JSON strings
49
+ * // and parses them itself — auto-parsing would hand back objects.
50
+ * const redis = new Redis({ url, token, automaticDeserialization: false });
51
+ * const client: RedisClient = {
52
+ * get: (k) => redis.get(k),
53
+ * set: (k, v, ttl) => redis.set(k, v, { ex: ttl }),
54
+ * expire: (k, s) => redis.expire(k, s),
55
+ * del: (k) => redis.del(k),
56
+ * sadd: (k, ...m) => redis.sadd(k, m[0], ...m.slice(1)),
57
+ * smembers: (k) => redis.smembers(k),
58
+ * };
59
+ * ```
7
60
  */
8
61
  export interface RedisClient {
62
+ /** `GET key` — the stored string, or null when absent. */
9
63
  get(key: string): Promise<string | null>;
10
- set(key: string, value: string, ...args: unknown[]): Promise<unknown>;
11
- del(key: string | string[]): Promise<number>;
12
- sadd(key: string, ...members: string[]): Promise<number>;
64
+ /**
65
+ * `SET key value EX ttlSeconds` — store with an expiry. The TTL is a
66
+ * required positional argument so no adapter can forget it.
67
+ */
68
+ set(key: string, value: string, ttlSeconds: number): Promise<unknown>;
69
+ /** `EXPIRE key seconds` — set/refresh a key's TTL (used for tag sets). */
70
+ expire(key: string, seconds: number): Promise<unknown>;
71
+ /** `DEL key` — delete a single key. Multi-key deletes loop per key. */
72
+ del(key: string): Promise<unknown>;
73
+ /** `SADD key member [member ...]` — add members to a set. */
74
+ sadd(key: string, ...members: string[]): Promise<unknown>;
75
+ /** `SMEMBERS key` — all members of a set. */
13
76
  smembers(key: string): Promise<string[]>;
14
77
  }
15
78
 
@@ -23,8 +86,9 @@ const TAG_PREFIX = 'timber:tag:';
23
86
  * invalidations. Tag-based invalidation uses Redis Sets to track which keys
24
87
  * belong to which tags.
25
88
  *
26
- * Bring your own Redis client — any client implementing the RedisClient
27
- * interface works (ioredis, node-redis, @upstash/redis, etc.).
89
+ * Bring your own Redis client wrapped to the {@link RedisClient} contract —
90
+ * see the adapter examples on the interface for ioredis, node-redis v4, and
91
+ * @upstash/redis.
28
92
  */
29
93
  export class RedisCacheHandler implements CacheHandler {
30
94
  private client: RedisClient;
@@ -62,7 +126,7 @@ export class RedisCacheHandler implements CacheHandler {
62
126
  // We use 2x TTL + 60s as the Redis expiry so stale entries remain
63
127
  // available for SWR background refetches.
64
128
  const redisTtlSeconds = Math.max(opts.ttl * 2 + 60, 120);
65
- await this.client.set(ck, payload, 'EX', redisTtlSeconds);
129
+ await this.client.set(ck, payload, redisTtlSeconds);
66
130
 
67
131
  // Track key membership in each tag set
68
132
  for (const tag of opts.tags) {
@@ -79,10 +143,10 @@ export class RedisCacheHandler implements CacheHandler {
79
143
  const tk = this.tagKey(opts.tag);
80
144
  const keys = await this.client.smembers(tk);
81
145
 
82
- if (keys.length > 0) {
83
- const cacheKeys = keys.map((k) => this.cacheKey(k));
84
- await this.client.del(cacheKeys);
85
- }
146
+ // One del per key: the only multi-key shape every client accepts.
147
+ // ioredis takes del(...keys), node-redis takes del(key | key[]), and
148
+ // @upstash/redis takes del(...keys) but breaks on an array argument.
149
+ await Promise.all(keys.map((k) => this.client.del(this.cacheKey(k))));
86
150
 
87
151
  // Clean up the tag set itself
88
152
  await this.client.del(tk);
@@ -2,20 +2,83 @@
2
2
  * Deterministic JSON serialization with sorted object keys.
3
3
  * Used for cache key generation — ensures { a: 1, b: 2 } and { b: 2, a: 1 }
4
4
  * produce the same string.
5
+ *
6
+ * Matches JSON.stringify semantics for toJSON: Date serializes to its ISO
7
+ * string, URL to its href, and class instances with a custom toJSON use it.
8
+ * Map and Set serialize their entries explicitly (insertion-order
9
+ * independent). Values that would serialize lossily — functions, symbols,
10
+ * and non-plain objects with no enumerable own state (RegExp, class
11
+ * instances holding data behind getters or internal slots) — throw instead:
12
+ * every such value would collapse to the same cache key, silently returning
13
+ * one argument's cached data for another (TIM-1020).
5
14
  */
6
15
  export function stableStringify(value: unknown): string {
7
- if (value === null || value === undefined) return JSON.stringify(value);
8
- if (typeof value !== 'object') return JSON.stringify(value);
16
+ if (value === null || value === undefined) return String(value);
17
+
18
+ const type = typeof value;
19
+ if (type === 'function' || type === 'symbol') {
20
+ throw new TypeError(
21
+ `stableStringify: cannot serialize a ${type} into a stable cache key — ` +
22
+ 'all such values would collide. Provide an explicit `key` option to cache().'
23
+ );
24
+ }
25
+ if (type !== 'object') return JSON.stringify(value);
26
+
9
27
  if (Array.isArray(value)) {
10
28
  return '[' + value.map((item) => stableStringify(item)).join(',') + ']';
11
29
  }
12
30
 
31
+ // Honor toJSON like JSON.stringify does (Date → ISO string, URL → href,
32
+ // custom class serialization). JSON.stringify does not re-invoke toJSON
33
+ // on the value it returns, so a toJSON that returns `this` falls through
34
+ // to plain own-property serialization instead of recursing forever.
35
+ const withToJSON = value as { toJSON?: () => unknown };
36
+ if (typeof withToJSON.toJSON === 'function') {
37
+ const json = withToJSON.toJSON();
38
+ if (json !== value) return stableStringify(json);
39
+ }
40
+
41
+ // Map/Set have no enumerable own properties — serialize entries explicitly.
42
+ // Entries are sorted after serialization so insertion order doesn't change
43
+ // the key. The Map(/Set( prefixes can't collide with object ('{'), array
44
+ // ('['), or string ('"') output.
45
+ if (value instanceof Map) {
46
+ const entries = [...value.entries()].map(
47
+ ([k, v]) => stableStringify(k) + '=>' + stableStringify(v)
48
+ );
49
+ return 'Map(' + entries.sort().join(',') + ')';
50
+ }
51
+ if (value instanceof Set) {
52
+ const members = [...value].map((member) => stableStringify(member));
53
+ return 'Set(' + members.sort().join(',') + ')';
54
+ }
55
+
13
56
  const obj = value as Record<string, unknown>;
14
57
  const keys = Object.keys(obj).sort();
15
58
  const pairs: string[] = [];
16
59
  for (const key of keys) {
17
60
  if (obj[key] === undefined) continue;
61
+ // A callable own toJSON only reaches this loop via the returns-`this`
62
+ // fall-through above. It's the serialization protocol, not data —
63
+ // JSON.stringify omits it too.
64
+ if (key === 'toJSON' && typeof obj[key] === 'function') continue;
18
65
  pairs.push(JSON.stringify(key) + ':' + stableStringify(obj[key]));
19
66
  }
67
+
68
+ // A non-plain object that serializes to '{}' carries its state behind
69
+ // getters or internal slots (RegExp, sessions, ORM entities). Serializing
70
+ // it would give every instance the same cache key.
71
+ if (pairs.length === 0) {
72
+ const proto = Object.getPrototypeOf(value);
73
+ if (proto !== Object.prototype && proto !== null) {
74
+ const name = (value as object).constructor?.name || 'Object';
75
+ throw new TypeError(
76
+ `stableStringify: ${name} instance has no serializable own state — ` +
77
+ 'all instances would share one cache key. Provide an explicit `key` ' +
78
+ 'option to cache(), or add a toJSON() method.'
79
+ );
80
+ }
81
+ }
82
+
20
83
  return '{' + pairs.join(',') + '}';
21
84
  }
@@ -3,6 +3,7 @@ import { stableStringify } from './stable-stringify';
3
3
  import { createSingleflight } from './singleflight';
4
4
  import { addSpanEventSync } from '../server/tracing.js';
5
5
  import { logSwrRefetchFailed } from '../server/logger.js';
6
+ import { getWaitUntil } from '../server/waituntil-bridge.js';
6
7
  import { fnv1aHash } from './fast-hash.js';
7
8
 
8
9
  const defaultSingleflight = createSingleflight();
@@ -44,7 +45,9 @@ let hasWarnedUnstableId = false;
44
45
  * - Singleflight: concurrent misses → single execution
45
46
  * - SWR: serve stale immediately, background refetch
46
47
  * - Tags as string[] or function of args
47
- * - No ALS dependency
48
+ * - No ALS dependency for correctness — cache() works outside a request
49
+ * context. The waitUntil ALS bridge is read optionally so the SWR
50
+ * background refetch survives handler return on Workers/Lambda.
48
51
  *
49
52
  * Cache hits/misses are recorded as OTEL span events on the enclosing
50
53
  * span (not child spans). The DevSpanProcessor reads these for dev log output.
@@ -110,18 +113,27 @@ export function createCache<Fn extends (...args: any[]) => Promise<any>>(
110
113
  stale: true,
111
114
  });
112
115
  // Serve stale immediately, trigger background refetch
113
- sf.do(`swr:${key}`, async () => {
114
- try {
115
- const fresh = await fn(...args);
116
- const tags = resolveTags(opts, args);
117
- await handler.set(key, fresh, { ttl: opts.ttl, tags });
118
- } catch (err) {
119
- // Failed refetch — stale entry continues to be served.
120
- logSwrRefetchFailed({ cacheKey: key, error: err });
121
- }
122
- }).catch(() => {
123
- // Singleflight promise rejection handled — stale continues.
124
- });
116
+ const refetch = sf
117
+ .do(`swr:${key}`, async () => {
118
+ try {
119
+ const fresh = await fn(...args);
120
+ const tags = resolveTags(opts, args);
121
+ await handler.set(key, fresh, { ttl: opts.ttl, tags });
122
+ } catch (err) {
123
+ // Failed refetch — stale entry continues to be served.
124
+ logSwrRefetchFailed({ cacheKey: key, error: err });
125
+ }
126
+ })
127
+ .catch(() => {
128
+ // Singleflight promise rejection handled — stale continues.
129
+ });
130
+ // Platforms that cancel work when the handler returns (Cloudflare
131
+ // Workers, Lambda) would otherwise kill the refetch mid-flight —
132
+ // stale data re-served forever. Register the (never-rejecting)
133
+ // refetch with the request's waitUntil; undefined outside a request
134
+ // context or on platforms without lifecycle extension (Node servers
135
+ // keep floating promises alive, so behavior there is unchanged).
136
+ getWaitUntil()?.(refetch);
125
137
  return cached.value as Awaited<ReturnType<Fn>>;
126
138
  }
127
139
 
package/src/cli.ts CHANGED
File without changes
@@ -18,6 +18,8 @@ import { setServerCallback, encodeReply, createFromFetch } from '../../rsc-runti
18
18
  import { getRouter, getRouterOrNull } from '#client-internal';
19
19
  import { setHardNavigating } from '../navigation-root.js';
20
20
  import { getClientDeploymentId, DEPLOYMENT_ID_HEADER, RELOAD_HEADER } from '../rsc-fetch.js';
21
+ import { decodeHeadHeader, HEAD_HEADER } from '../../shared/head-header.js';
22
+ import type { HeadElement } from '../head.js';
21
23
 
22
24
  export function setupServerActions(): void {
23
25
  setServerCallback(async (id: string, args: unknown[]) => {
@@ -54,7 +56,7 @@ export function setupServerActions(): void {
54
56
  }
55
57
  hasRevalidation = res.headers.get('X-Timber-Revalidation') === '1';
56
58
  hasRedirect = res.headers.get('X-Timber-Redirect') != null;
57
- headElementsJson = res.headers.get('X-Timber-Head');
59
+ headElementsJson = res.headers.get(HEAD_HEADER);
58
60
  return res;
59
61
  });
60
62
 
@@ -88,7 +90,10 @@ export function setupServerActions(): void {
88
90
  const router = getRouterOrNull();
89
91
  if (router) {
90
92
  try {
91
- const headElements = headElementsJson ? JSON.parse(headElementsJson) : null;
93
+ // Percent-encoded JSON — same wire format as the navigation path.
94
+ // A malformed header decodes to null (head update skipped), it
95
+ // does not fail the revalidation. See TIM-1058.
96
+ const headElements = decodeHeadHeader(headElementsJson) as HeadElement[] | null;
92
97
  router.applyRevalidation(wrapper._tree, headElements);
93
98
  } catch (e) {
94
99
  console.error('[timber] applyRevalidation failed after server action', e);
@@ -22,7 +22,7 @@ import {
22
22
  setupNavigationApi,
23
23
  type NavigationApiController,
24
24
  } from '../navigation-api.js';
25
- import { getScrollY } from './scroll.js';
25
+ import { getScrollY, scrollToHashTarget } from './scroll.js';
26
26
 
27
27
  export interface RouterInitOptions {
28
28
  initialElement?: unknown;
@@ -77,6 +77,7 @@ export function createTimberRouter(options?: RouterInitOptions): RouterInitResul
77
77
  },
78
78
  getCurrentUrl: () => window.location.pathname + window.location.search,
79
79
  getScrollY,
80
+ scrollToHash: scrollToHashTarget,
80
81
 
81
82
  decodeRsc: (fetchPromise: Promise<Response>) => {
82
83
  return createFromFetch(fetchPromise);
@@ -16,6 +16,29 @@
16
16
  * naturally. We don't need to auto-detect overflow containers; only
17
17
  * explicitly marked containers are tracked.
18
18
  */
19
+ /**
20
+ * Scroll the element matching a URL #fragment into view.
21
+ *
22
+ * Mirrors browser anchor behavior: looks up the decoded fragment by id,
23
+ * then by anchor name. Returns true when a matching element was found and
24
+ * scrolled; false otherwise (the router falls back to scroll-to-top, same
25
+ * as a full page load with an unknown fragment). See TIM-1035.
26
+ */
27
+ export function scrollToHashTarget(hash: string): boolean {
28
+ const fragment = hash.startsWith('#') ? hash.slice(1) : hash;
29
+ if (!fragment) return false;
30
+ let id: string;
31
+ try {
32
+ id = decodeURIComponent(fragment);
33
+ } catch {
34
+ return false;
35
+ }
36
+ const target = document.getElementById(id) ?? document.getElementsByName(id)[0];
37
+ if (!target) return false;
38
+ target.scrollIntoView();
39
+ return true;
40
+ }
41
+
19
42
  export function getScrollY(): number {
20
43
  if (window.scrollY || document.documentElement.scrollTop || document.body.scrollTop) {
21
44
  return window.scrollY || document.documentElement.scrollTop || document.body.scrollTop;
@@ -101,6 +101,13 @@ interface TimberErrorBoundaryState {
101
101
  // duplicate router.navigate() calls.
102
102
  const _handledRedirects = new WeakSet<object>();
103
103
 
104
+ // Companion guard keyed by location: client-render redirect() (the
105
+ // next/navigation shim) throws a FRESH error instance on every render
106
+ // attempt, and React replays the errored render — so the WeakSet alone
107
+ // would schedule the navigation twice. While a navigation to a location
108
+ // is pending, further redirect errors for the same location are no-ops.
109
+ let _pendingRedirectLocation: string | null = null;
110
+
104
111
  // ─── Component ───────────────────────────────────────────────────────────────
105
112
 
106
113
  export class TimberErrorBoundary extends Component<
@@ -167,18 +174,25 @@ export class TimberErrorBoundary extends Component<
167
174
  // than once for a single caught error (dev-mode double render, and
168
175
  // additional boundaries above us in the tree). Only schedule the
169
176
  // navigation on the first render that sees this error instance.
170
- const alreadyHandled = _handledRedirects.has(error);
177
+ const alreadyHandled =
178
+ _handledRedirects.has(error) || _pendingRedirectLocation === parsed.location;
171
179
  if (!alreadyHandled) {
172
180
  _handledRedirects.add(error);
173
181
  const router = getRouterOrNull();
174
182
  if (router) {
183
+ _pendingRedirectLocation = parsed.location;
175
184
  // Schedule the navigation outside the render phase to avoid
176
185
  // setState-during-render warnings from router state updates.
177
186
  queueMicrotask(() => {
178
- router.navigate(parsed.location, { replace: true }).catch(() => {
179
- // Fall back to a hard navigation if the soft navigation fails.
180
- window.location.href = parsed.location;
181
- });
187
+ router
188
+ .navigate(parsed.location, { replace: true })
189
+ .catch(() => {
190
+ // Fall back to a hard navigation if the soft navigation fails.
191
+ window.location.href = parsed.location;
192
+ })
193
+ .finally(() => {
194
+ _pendingRedirectLocation = null;
195
+ });
182
196
  });
183
197
  } else {
184
198
  // No router available (shouldn't happen post-bootstrap) — fall
@@ -536,7 +536,10 @@ export const Link: LinkFunction = function LinkImpl(props: any) {
536
536
  event.preventDefault();
537
537
 
538
538
  const shouldScroll = scroll !== false;
539
- const absoluteHref = resolved.pathname + resolved.search;
539
+ // Keep the #fragment — the router commits it to the address bar and
540
+ // scrolls to the matching element after render. The hash is stripped
541
+ // from the RSC fetch URL inside the router (TIM-1035).
542
+ const absoluteHref = resolved.pathname + resolved.search + resolved.hash;
540
543
 
541
544
  startTransition(async () => {
542
545
  await router.navigate(absoluteHref, { scroll: shouldScroll });
@@ -266,7 +266,11 @@ export function setupNavigationApi(callbacks: NavigationApiCallbacks): Navigatio
266
266
  scroll: 'manual',
267
267
  focusReset: 'manual',
268
268
  async handler() {
269
- await callbacks.onExternalNavigate(url, {
269
+ // Keep the #fragment — router.navigate splits it back off for the
270
+ // RSC fetch and scrolls to the matching element after render
271
+ // (TIM-1035). Traversals above stay hash-less: history-stack keys
272
+ // use pathname + search.
273
+ await callbacks.onExternalNavigate(url + destUrl.hash, {
270
274
  replace: event.navigationType === 'replace',
271
275
  signal: event.signal,
272
276
  scroll: linkMeta?.scroll,
@@ -58,6 +58,22 @@ function getTransitionCounter(): { id: number } {
58
58
  return g[NAV_TRANSITION_KEY] as { id: number };
59
59
  }
60
60
 
61
+ /**
62
+ * Invalidate all in-flight navigation transitions. Any navigateTransition()
63
+ * call whose perform() has not yet committed will reject with AbortError
64
+ * instead of committing its element.
65
+ *
66
+ * Called by the router when a render supersedes in-flight navigations
67
+ * WITHOUT going through navigateTransition() — the cached popstate replay
68
+ * renders via transitionRender(), which doesn't bump the counter, so a
69
+ * stale forward navigation's setElement would otherwise pass the
70
+ * `counter.id !== transId` guard and commit the forward page over the
71
+ * replayed back page (TIM-1022).
72
+ */
73
+ export function supersedeNavigationTransitions(): void {
74
+ getTransitionCounter().id += 1;
75
+ }
76
+
61
77
  // ─── Hard Navigation Guard ──────────────────────────────────────
62
78
 
63
79
  /**