dinou 4.0.13 → 4.0.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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
+ ## [4.0.14] - 2026-06-29
9
+ ### Added
10
+ - **Soft Redirects**: Exposes the SPA transition navigation internally via `window.__DINOU_ROUTER_NAVIGATE__` to enable smooth client-side redirects from both Server Functions and RSC payloads, keeping the React client state intact and preventing full browser page reloads.
11
+ ### Fixed
12
+ - **External Links Interception**: Restored native browser navigation for external domains, protocol-relative links (`//...`), and special protocols (`mailto:`, `tel:`, etc.) by preventing the SPA router from capturing or prefetching non-internal routes.
13
+
8
14
  ## [4.0.13] - 2026-06-29
9
15
 
10
16
  ### Added
@@ -9,7 +9,7 @@ import {
9
9
  import { createFromFetch } from "react-server-dom-webpack/client";
10
10
  import { hydrateRoot } from "react-dom/client";
11
11
  import { RouterContext } from "./navigation.js";
12
- import { resolveUrl } from "./navigation-utils.js";
12
+ import { resolveUrl, isExternalUrl } from "./navigation-utils.js";
13
13
 
14
14
  // ====================================================================
15
15
  // 1. GLOBAL STATE (Outside the component)
@@ -167,7 +167,7 @@ function Router() {
167
167
  }
168
168
 
169
169
  const href = anchor.getAttribute("href");
170
- if (!href || href.startsWith("mailto:") || href.startsWith("tel:"))
170
+ if (!href || href.startsWith("mailto:") || href.startsWith("tel:") || isExternalUrl(href))
171
171
  return;
172
172
 
173
173
  // We use the unified helper
@@ -9,7 +9,7 @@ import {
9
9
  import { createFromFetch } from "@roggc/react-server-dom-esm/client";
10
10
  import { hydrateRoot } from "react-dom/client";
11
11
  import { RouterContext } from "./navigation.js";
12
- import { resolveUrl } from "./navigation-utils.js";
12
+ import { resolveUrl, isExternalUrl } from "./navigation-utils.js";
13
13
 
14
14
  // ====================================================================
15
15
  // 1. GLOBAL STATE (Outside the component)
@@ -167,7 +167,7 @@ function Router() {
167
167
  }
168
168
 
169
169
  const href = anchor.getAttribute("href");
170
- if (!href || href.startsWith("mailto:") || href.startsWith("tel:"))
170
+ if (!href || href.startsWith("mailto:") || href.startsWith("tel:") || isExternalUrl(href))
171
171
  return;
172
172
 
173
173
  // We use the unified helper
@@ -1,15 +1,22 @@
1
1
  // dinou/core/client-redirect.jsx
2
2
  "use client";
3
3
 
4
- import { useEffect } from "react";
5
4
  import { useRouter } from "./navigation.js";
6
5
 
7
6
  export function ClientRedirect({ to }) {
8
7
  const router = useRouter();
9
8
 
10
- useEffect(() => {
11
- router.replace(to);
12
- }, [to, router]);
9
+ if (typeof window !== "undefined") {
10
+ Promise.resolve().then(() => {
11
+ if (window.__DINOU_ROUTER_NAVIGATE__) {
12
+ window.__DINOU_ROUTER_NAVIGATE__(to, { replace: true });
13
+ } else {
14
+ router.replace(to);
15
+ }
16
+ });
17
+ // Suspend to prevent React from committing this intermediate page
18
+ throw new Promise(() => {});
19
+ }
13
20
 
14
21
  return null;
15
22
  }
@@ -9,7 +9,7 @@ import {
9
9
  import { createFromFetch } from "react-server-dom-webpack/client";
10
10
  import { hydrateRoot } from "react-dom/client";
11
11
  import { RouterContext } from "./navigation.js";
12
- import { resolveUrl } from "./navigation-utils.js";
12
+ import { resolveUrl, isExternalUrl } from "./navigation-utils.js";
13
13
 
14
14
  // ====================================================================
15
15
  // 1. GLOBAL STATE (Outside the component)
@@ -68,10 +68,13 @@ const getRSCPayload = (rscKey, isPrefetch = false) => {
68
68
  fetch(payloadUrl).then((res) => {
69
69
  if (res.headers.has("x-rsc-redirect")) {
70
70
  const redirectUrl = res.headers.get("x-rsc-redirect");
71
+ cache.delete(url);
71
72
  if (!isPrefetch) {
72
- window.location.href = redirectUrl;
73
- } else {
74
- cache.delete(url);
73
+ if (window.__DINOU_ROUTER_NAVIGATE__) {
74
+ window.__DINOU_ROUTER_NAVIGATE__(redirectUrl, { replace: true });
75
+ } else {
76
+ window.location.href = redirectUrl;
77
+ }
75
78
  }
76
79
  // Return a promise that never resolves to avoid React Server DOM throwing "Connection closed"
77
80
  return new Promise(() => {});
@@ -93,18 +96,6 @@ function Router() {
93
96
  const [isPending, startTransition] = useTransition();
94
97
  const [version, setVersion] = useState(0);
95
98
 
96
- // 🔌 EFFECT 1: Expose Global Prefetch
97
- useEffect(() => {
98
- window.__DINOU_PREFETCH__ = (url) => {
99
- // 🛡️ PREFETCH PROTECTION: If it's a local hash, do nothing
100
- if (isHashChangeOnly(url)) return;
101
- getRSCPayload(url, true);
102
- };
103
-
104
- // Hydration
105
- document.body.setAttribute("data-hydrated", "true");
106
- }, []); // Only on mount
107
-
108
99
  // 🧭 NAVIGATE FUNCTION (Core Logic)
109
100
  const navigate = (href, options = {}) => {
110
101
  const finalPath = resolveUrl(href, window.location.pathname);
@@ -150,6 +141,26 @@ function Router() {
150
141
  });
151
142
  };
152
143
 
144
+ // 🔌 EFFECT 1: Expose Global Prefetch & Navigation
145
+ useEffect(() => {
146
+ window.__DINOU_PREFETCH__ = (url) => {
147
+ // 🛡️ PREFETCH PROTECTION: If it's a local hash, do nothing
148
+ if (isHashChangeOnly(url)) return;
149
+ getRSCPayload(url, true);
150
+ };
151
+
152
+ window.__DINOU_ROUTER_NAVIGATE__ = navigate;
153
+
154
+ // Hydration
155
+ document.body.setAttribute("data-hydrated", "true");
156
+
157
+ return () => {
158
+ if (window.__DINOU_ROUTER_NAVIGATE__ === navigate) {
159
+ window.__DINOU_ROUTER_NAVIGATE__ = undefined;
160
+ }
161
+ };
162
+ }, [navigate]);
163
+
153
164
  const back = () => window.history.back();
154
165
  const forward = () => window.history.forward();
155
166
  const refresh = () => {
@@ -188,7 +199,7 @@ function Router() {
188
199
  }
189
200
 
190
201
  const href = anchor.getAttribute("href");
191
- if (!href || href.startsWith("mailto:") || href.startsWith("tel:"))
202
+ if (!href || href.startsWith("mailto:") || href.startsWith("tel:") || isExternalUrl(href))
192
203
  return;
193
204
 
194
205
  // We use the unified helper
@@ -10,7 +10,7 @@ import {
10
10
  import { createFromFetch } from "@roggc/react-server-dom-esm/client";
11
11
  import { hydrateRoot } from "react-dom/client";
12
12
  import { RouterContext } from "./navigation.js";
13
- import { resolveUrl } from "./navigation-utils.js";
13
+ import { resolveUrl, isExternalUrl } from "./navigation-utils.js";
14
14
 
15
15
  // ====================================================================
16
16
  // 1. GLOBAL STATE (Outside the component)
@@ -69,10 +69,13 @@ const getRSCPayload = (rscKey, isPrefetch = false) => {
69
69
  fetch(payloadUrl).then((res) => {
70
70
  if (res.headers.has("x-rsc-redirect")) {
71
71
  const redirectUrl = res.headers.get("x-rsc-redirect");
72
+ cache.delete(url);
72
73
  if (!isPrefetch) {
73
- window.location.href = redirectUrl;
74
- } else {
75
- cache.delete(url);
74
+ if (window.__DINOU_ROUTER_NAVIGATE__) {
75
+ window.__DINOU_ROUTER_NAVIGATE__(redirectUrl, { replace: true });
76
+ } else {
77
+ window.location.href = redirectUrl;
78
+ }
76
79
  }
77
80
  // Return a promise that never resolves to avoid React Server DOM throwing "Connection closed"
78
81
  return new Promise(() => {});
@@ -94,18 +97,6 @@ function Router() {
94
97
  const [isPending, startTransition] = useTransition();
95
98
  const [version, setVersion] = useState(0);
96
99
 
97
- // 🔌 EFFECT 1: Expose Global Prefetch
98
- useEffect(() => {
99
- window.__DINOU_PREFETCH__ = (url) => {
100
- // 🛡️ PREFETCH PROTECTION: If it's a local hash, do nothing
101
- if (isHashChangeOnly(url)) return;
102
- getRSCPayload(url, true);
103
- };
104
-
105
- // Hydration
106
- document.body.setAttribute("data-hydrated", "true");
107
- }, []); // Only on mount
108
-
109
100
  // 🧭 NAVIGATE FUNCTION (Core Logic)
110
101
  const navigate = (href, options = {}) => {
111
102
  const finalPath = resolveUrl(href, window.location.pathname);
@@ -151,6 +142,26 @@ function Router() {
151
142
  });
152
143
  };
153
144
 
145
+ // 🔌 EFFECT 1: Expose Global Prefetch & Navigation
146
+ useEffect(() => {
147
+ window.__DINOU_PREFETCH__ = (url) => {
148
+ // 🛡️ PREFETCH PROTECTION: If it's a local hash, do nothing
149
+ if (isHashChangeOnly(url)) return;
150
+ getRSCPayload(url, true);
151
+ };
152
+
153
+ window.__DINOU_ROUTER_NAVIGATE__ = navigate;
154
+
155
+ // Hydration
156
+ document.body.setAttribute("data-hydrated", "true");
157
+
158
+ return () => {
159
+ if (window.__DINOU_ROUTER_NAVIGATE__ === navigate) {
160
+ window.__DINOU_ROUTER_NAVIGATE__ = undefined;
161
+ }
162
+ };
163
+ }, [navigate]);
164
+
154
165
  const back = () => window.history.back();
155
166
  const forward = () => window.history.forward();
156
167
  const refresh = () => {
@@ -189,7 +200,7 @@ function Router() {
189
200
  }
190
201
 
191
202
  const href = anchor.getAttribute("href");
192
- if (!href || href.startsWith("mailto:") || href.startsWith("tel:"))
203
+ if (!href || href.startsWith("mailto:") || href.startsWith("tel:") || isExternalUrl(href))
193
204
  return;
194
205
 
195
206
  // We use the unified helper
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { useRouter, usePathname } from "./navigation.js";
4
- import { resolveUrl } from "./navigation-utils.js";
4
+ import { resolveUrl, isExternalUrl } from "./navigation-utils.js";
5
5
 
6
6
  export function Link({
7
7
  href,
@@ -15,14 +15,14 @@ export function Link({
15
15
  const resolvedHref = resolveUrl(href, pathname);
16
16
 
17
17
  const handlePrefetch = () => {
18
- if (!prefetch || !href || fresh) return;
18
+ if (!prefetch || !href || fresh || isExternalUrl(href)) return;
19
19
  if (window.__DINOU_PREFETCH__) {
20
20
  window.__DINOU_PREFETCH__(resolvedHref);
21
21
  }
22
22
  };
23
23
 
24
24
  const handleClick = (e) => {
25
- if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
25
+ if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || isExternalUrl(href)) return;
26
26
 
27
27
  e.preventDefault();
28
28
  push(href, { fresh });
@@ -1,4 +1,35 @@
1
+ export function isExternalUrl(href) {
2
+ if (!href) return false;
3
+
4
+ // Protocol-relative (e.g. //google.com)
5
+ if (href.startsWith("//")) {
6
+ return true;
7
+ }
8
+
9
+ // Absolute with protocol (e.g. https://google.com)
10
+ if (href.includes("://")) {
11
+ try {
12
+ const origin = typeof window !== "undefined" ? window.location.origin : "http://localhost";
13
+ const url = new URL(href, origin);
14
+ return url.origin !== origin;
15
+ } catch (e) {
16
+ return true;
17
+ }
18
+ }
19
+
20
+ // Other protocols (mailto:, tel:, javascript:, etc.)
21
+ if (/^[a-zA-Z0-9+-.]+:[^//]/.test(href) || href.startsWith("mailto:") || href.startsWith("tel:") || href.startsWith("javascript:")) {
22
+ return true;
23
+ }
24
+
25
+ return false;
26
+ }
27
+
1
28
  export function resolveUrl(href, currentPathname) {
29
+ if (isExternalUrl(href)) {
30
+ return href;
31
+ }
32
+
2
33
  const origin = typeof window !== "undefined" ? window.location.origin : "http://localhost";
3
34
 
4
35
  if (href.startsWith("/") || href.includes("://")) {
@@ -4,6 +4,16 @@ function isSafeRedirect(url) {
4
4
  return typeof url === "string" && url.startsWith("/") && !url.startsWith("//");
5
5
  }
6
6
 
7
+ function executeRedirect(url) {
8
+ const safeUrl = isSafeRedirect(url) ? url : "/";
9
+ const isInternal = safeUrl.startsWith("/") && !safeUrl.startsWith("//");
10
+ if (isInternal && typeof window !== "undefined" && window.__DINOU_ROUTER_NAVIGATE__) {
11
+ window.__DINOU_ROUTER_NAVIGATE__(safeUrl);
12
+ } else if (typeof window !== "undefined") {
13
+ window.location.href = safeUrl;
14
+ }
15
+ }
16
+
7
17
  function createServerFunctionProxy(id) {
8
18
  return new Proxy(() => { }, {
9
19
  apply: async (_target, _thisArg, args) => {
@@ -37,8 +47,8 @@ function createServerFunctionProxy(id) {
37
47
  // Check header first
38
48
  const redirectUrl = res.headers.get("X-Dinou-Redirect");
39
49
  if (redirectUrl) {
40
- window.location.href = isSafeRedirect(redirectUrl) ? redirectUrl : "/";
41
- return new Promise(() => { });
50
+ executeRedirect(redirectUrl);
51
+ return Promise.resolve();
42
52
  }
43
53
 
44
54
  const contentType = res.headers.get("content-type") || "";
@@ -54,8 +64,8 @@ function createServerFunctionProxy(id) {
54
64
  if (contentType.includes("application/json")) {
55
65
  const data = await res.json();
56
66
  if (data && data.redirect) {
57
- window.location.href = isSafeRedirect(data.redirect) ? data.redirect : "/";
58
- return new Promise(() => { });
67
+ executeRedirect(data.redirect);
68
+ return Promise.resolve();
59
69
  }
60
70
  return data;
61
71
  }
@@ -82,7 +92,7 @@ function createServerFunctionProxy(id) {
82
92
  const payload = JSON.parse(buffer.slice(2));
83
93
  if (payload.type === "redirect") {
84
94
  isRedirecting = true;
85
- window.location.href = isSafeRedirect(payload.url) ? payload.url : "/";
95
+ executeRedirect(payload.url);
86
96
  } else if (payload.type === "cookie") {
87
97
  document.cookie = payload.cookie;
88
98
  }
@@ -119,7 +129,7 @@ function createServerFunctionProxy(id) {
119
129
  const payload = JSON.parse(line.slice(2));
120
130
  if (payload.type === "redirect") {
121
131
  isRedirecting = true;
122
- window.location.href = isSafeRedirect(payload.url) ? payload.url : "/";
132
+ executeRedirect(payload.url);
123
133
  } else if (payload.type === "cookie") {
124
134
  document.cookie = payload.cookie;
125
135
  }
@@ -130,18 +140,14 @@ function createServerFunctionProxy(id) {
130
140
  cleanChunk += line + "\n";
131
141
  }
132
142
  }
133
-
134
143
  if (cleanChunk) {
135
144
  controller.enqueue(encoder.encode(cleanChunk));
136
145
  }
137
146
  }
138
147
  }
148
+ controller.close();
139
149
  } catch (err) {
140
150
  controller.error(err);
141
- } finally {
142
- if (!isRedirecting) {
143
- controller.close();
144
- }
145
151
  }
146
152
  },
147
153
  });
@@ -5,6 +5,16 @@ function isSafeRedirect(url) {
5
5
  return typeof url === "string" && url.startsWith("/") && !url.startsWith("//");
6
6
  }
7
7
 
8
+ function executeRedirect(url) {
9
+ const safeUrl = isSafeRedirect(url) ? url : "/";
10
+ const isInternal = safeUrl.startsWith("/") && !safeUrl.startsWith("//");
11
+ if (isInternal && typeof window !== "undefined" && window.__DINOU_ROUTER_NAVIGATE__) {
12
+ window.__DINOU_ROUTER_NAVIGATE__(safeUrl);
13
+ } else if (typeof window !== "undefined") {
14
+ window.location.href = safeUrl;
15
+ }
16
+ }
17
+
8
18
  export function createServerFunctionProxy(id) {
9
19
  return new Proxy(() => {}, {
10
20
  apply: async (_target, _thisArg, args) => {
@@ -38,8 +48,8 @@ export function createServerFunctionProxy(id) {
38
48
  // Check header first
39
49
  const redirectUrl = res.headers.get("X-Dinou-Redirect");
40
50
  if (redirectUrl) {
41
- window.location.href = isSafeRedirect(redirectUrl) ? redirectUrl : "/";
42
- return new Promise(() => {});
51
+ executeRedirect(redirectUrl);
52
+ return Promise.resolve();
43
53
  }
44
54
 
45
55
  const contentType = res.headers.get("content-type") || "";
@@ -55,8 +65,8 @@ export function createServerFunctionProxy(id) {
55
65
  if (contentType.includes("application/json")) {
56
66
  const data = await res.json();
57
67
  if (data && data.redirect) {
58
- window.location.href = isSafeRedirect(data.redirect) ? data.redirect : "/";
59
- return new Promise(() => {});
68
+ executeRedirect(data.redirect);
69
+ return Promise.resolve();
60
70
  }
61
71
  return data;
62
72
  }
@@ -83,7 +93,7 @@ export function createServerFunctionProxy(id) {
83
93
  const payload = JSON.parse(buffer.slice(2));
84
94
  if (payload.type === "redirect") {
85
95
  isRedirecting = true;
86
- window.location.href = isSafeRedirect(payload.url) ? payload.url : "/";
96
+ executeRedirect(payload.url);
87
97
  } else if (payload.type === "cookie") {
88
98
  document.cookie = payload.cookie;
89
99
  }
@@ -120,7 +130,7 @@ export function createServerFunctionProxy(id) {
120
130
  const payload = JSON.parse(line.slice(2));
121
131
  if (payload.type === "redirect") {
122
132
  isRedirecting = true;
123
- window.location.href = isSafeRedirect(payload.url) ? payload.url : "/";
133
+ executeRedirect(payload.url);
124
134
  } else if (payload.type === "cookie") {
125
135
  document.cookie = payload.cookie;
126
136
  }
@@ -137,12 +147,9 @@ export function createServerFunctionProxy(id) {
137
147
  }
138
148
  }
139
149
  }
150
+ controller.close();
140
151
  } catch (err) {
141
152
  controller.error(err);
142
- } finally {
143
- if (!isRedirecting) {
144
- controller.close();
145
- }
146
153
  }
147
154
  },
148
155
  });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dinou-ejected",
3
- "version": "4.0.13",
3
+ "version": "4.0.14",
4
4
  "main": "index.js",
5
5
  "private": true,
6
6
  "exports": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dinou",
3
- "version": "4.0.13",
3
+ "version": "4.0.14",
4
4
  "description": "Full-Stack React 19 framework with React Server Components, Server Functions, and Streaming SSR.",
5
5
  "main": "index.js",
6
6
  "bin": {