rwsdk 0.1.26 → 0.1.28

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.
@@ -77,7 +77,9 @@ export const initClient = async ({ transport = fetchTransport, hydrateRootOption
77
77
  const [streamData, setStreamData] = React.useState(rscPayload);
78
78
  const [_isPending, startTransition] = React.useTransition();
79
79
  transportContext.setRscPayload = (v) => startTransition(() => setStreamData(v));
80
- return _jsx(_Fragment, { children: React.use(streamData).node });
80
+ return (_jsx(_Fragment, { children: streamData
81
+ ? React.use(streamData).node
82
+ : null }));
81
83
  }
82
84
  hydrateRoot(rootEl, _jsx(Content, {}), {
83
85
  onUncaughtError: (error, { componentStack }) => {
@@ -1,6 +1,9 @@
1
+ export interface ClientNavigationOptions {
2
+ onNavigate?: () => void;
3
+ scrollToTop?: boolean;
4
+ scrollBehavior?: "auto" | "smooth" | "instant";
5
+ }
1
6
  export declare function validateClickEvent(event: MouseEvent, target: HTMLElement): boolean;
2
- export declare function initClientNavigation(opts?: {
3
- onNavigate: () => void;
4
- }): {
7
+ export declare function initClientNavigation(opts?: ClientNavigationOptions): {
5
8
  handleResponse: (response: Response) => boolean;
6
9
  };
@@ -14,6 +14,9 @@ export function validateClickEvent(event, target) {
14
14
  if (!href) {
15
15
  return false;
16
16
  }
17
+ if (href.includes("#")) {
18
+ return false;
19
+ }
17
20
  // Skip if target="_blank" or similar
18
21
  if (link.target && link.target !== "_self") {
19
22
  return false;
@@ -27,12 +30,73 @@ export function validateClickEvent(event, target) {
27
30
  }
28
31
  return true;
29
32
  }
30
- export function initClientNavigation(opts = {
31
- onNavigate: async function onNavigate() {
32
- // @ts-expect-error
33
- await globalThis.__rsc_callServer();
34
- },
35
- }) {
33
+ export function initClientNavigation(opts = {}) {
34
+ // Merge user options with defaults
35
+ const options = {
36
+ onNavigate: async function onNavigate() {
37
+ // @ts-expect-error
38
+ await globalThis.__rsc_callServer();
39
+ },
40
+ scrollToTop: true,
41
+ scrollBehavior: "instant",
42
+ ...opts,
43
+ };
44
+ // Prevent browser's automatic scroll restoration for popstate
45
+ if ("scrollRestoration" in history) {
46
+ history.scrollRestoration = "manual";
47
+ }
48
+ // Set up scroll behavior management
49
+ let popStateWasCalled = false;
50
+ let savedScrollPosition = null;
51
+ const observer = new MutationObserver(() => {
52
+ if (popStateWasCalled && savedScrollPosition) {
53
+ // Restore scroll position for popstate navigation (always instant)
54
+ window.scrollTo({
55
+ top: savedScrollPosition.y,
56
+ left: savedScrollPosition.x,
57
+ behavior: "instant",
58
+ });
59
+ savedScrollPosition = null;
60
+ }
61
+ else if (options.scrollToTop && !popStateWasCalled) {
62
+ // Scroll to top for anchor click navigation (configurable)
63
+ window.scrollTo({
64
+ top: 0,
65
+ left: 0,
66
+ behavior: options.scrollBehavior,
67
+ });
68
+ // Update the current history entry with the new scroll position (top)
69
+ // This ensures that if we navigate back and then forward again,
70
+ // we return to the top position, not some previous scroll position
71
+ window.history.replaceState({
72
+ ...window.history.state,
73
+ scrollX: 0,
74
+ scrollY: 0,
75
+ }, "", window.location.href);
76
+ }
77
+ popStateWasCalled = false;
78
+ });
79
+ const handleScrollPopState = (event) => {
80
+ popStateWasCalled = true;
81
+ // Save the scroll position that the browser would have restored to
82
+ const state = event.state;
83
+ if (state &&
84
+ typeof state === "object" &&
85
+ "scrollX" in state &&
86
+ "scrollY" in state) {
87
+ savedScrollPosition = { x: state.scrollX, y: state.scrollY };
88
+ }
89
+ else {
90
+ // Fallback: try to get scroll position from browser's session history
91
+ // This is a best effort since we can't directly access the browser's stored position
92
+ savedScrollPosition = { x: window.scrollX, y: window.scrollY };
93
+ }
94
+ };
95
+ const main = document.querySelector("main") || document.body;
96
+ if (main) {
97
+ window.addEventListener("popstate", handleScrollPopState);
98
+ observer.observe(main, { childList: true, subtree: true });
99
+ }
36
100
  // Intercept all anchor tag clicks
37
101
  document.addEventListener("click", async function handleClickEvent(event) {
38
102
  // Prevent default navigation
@@ -43,12 +107,18 @@ export function initClientNavigation(opts = {
43
107
  const el = event.target;
44
108
  const a = el.closest("a");
45
109
  const href = a?.getAttribute("href");
110
+ // Save current scroll position before navigating
111
+ window.history.replaceState({
112
+ path: window.location.pathname,
113
+ scrollX: window.scrollX,
114
+ scrollY: window.scrollY,
115
+ }, "", window.location.href);
46
116
  window.history.pushState({ path: href }, "", window.location.origin + href);
47
- await opts.onNavigate();
117
+ await options.onNavigate();
48
118
  }, true);
49
119
  // Handle browser back/forward buttons
50
120
  window.addEventListener("popstate", async function handlePopState() {
51
- await opts.onNavigate();
121
+ await options.onNavigate();
52
122
  });
53
123
  // Return a handleResponse function for use with initClient
54
124
  return {
@@ -35,11 +35,10 @@ describe("clientNavigation", () => {
35
35
  closest: () => ({ getAttribute: () => undefined }),
36
36
  })).toBe(false);
37
37
  });
38
- it("should not have a target attribute", () => {
38
+ it("should not include an #hash", () => {
39
39
  expect(validateClickEvent(mockEvent, {
40
40
  closest: () => ({
41
- target: "_blank",
42
- getAttribute: () => "/test",
41
+ getAttribute: () => "/test#hash",
43
42
  hasAttribute: () => false,
44
43
  }),
45
44
  })).toBe(false);
@@ -1 +1,6 @@
1
1
  import "./shared";
2
+ export interface ClientNavigationOptions {
3
+ onNavigate?: () => void;
4
+ scrollToTop?: boolean;
5
+ scrollBehavior?: "auto" | "smooth" | "instant";
6
+ }
@@ -15,6 +15,7 @@ export type RwContext = {
15
15
  ssr: boolean;
16
16
  layouts?: React.FC<LayoutProps<any>>[];
17
17
  databases: Map<string, Kysely<any>>;
18
+ pageRouteResolved: PromiseWithResolvers<void> | undefined;
18
19
  };
19
20
  export type RouteMiddleware<T extends RequestInfo = RequestInfo> = (requestInfo: T) => Response | Promise<Response> | void | Promise<void> | Promise<Response | void>;
20
21
  type RouteFunction<T extends RequestInfo = RequestInfo> = (requestInfo: T) => Response | Promise<Response>;
@@ -41,6 +42,7 @@ export declare function defineRoutes<T extends RequestInfo = RequestInfo>(routes
41
42
  export declare function route<T extends RequestInfo = RequestInfo>(path: string, handler: RouteHandler<T>): RouteDefinition<T>;
42
43
  export declare function index<T extends RequestInfo = RequestInfo>(handler: RouteHandler<T>): RouteDefinition<T>;
43
44
  export declare function prefix<T extends RequestInfo = RequestInfo>(prefixPath: string, routes: Route<T>[]): Route<T>[];
45
+ export declare const wrapHandlerToThrowResponses: <T extends RequestInfo = RequestInfo>(handler: RouteFunction<T> | RouteComponent<T>) => RouteHandler<T>;
44
46
  export declare function layout<T extends RequestInfo = RequestInfo>(LayoutComponent: React.FC<LayoutProps<T>>, routes: Route<T>[]): Route<T>[];
45
47
  export declare function render<T extends RequestInfo = RequestInfo>(Document: React.FC<DocumentProps<T>>, routes: Route<T>[],
46
48
  /**
@@ -52,5 +54,5 @@ options?: {
52
54
  rscPayload?: boolean;
53
55
  ssr?: boolean;
54
56
  }): Route<T>[];
55
- export declare const isClientReference: (Component: React.FC<any>) => boolean;
57
+ export declare const isClientReference: (value: any) => boolean;
56
58
  export {};
@@ -97,7 +97,13 @@ export function defineRoutes(routes) {
97
97
  for (const h of handlers) {
98
98
  if (isRouteComponent(h)) {
99
99
  const requestInfo = getRequestInfo();
100
- const WrappedComponent = wrapWithLayouts(h, layouts || [], requestInfo);
100
+ const WrappedComponent = wrapWithLayouts(wrapHandlerToThrowResponses(h), layouts || [], requestInfo);
101
+ if (!isClientReference(WrappedComponent)) {
102
+ // context(justinvdm, 31 Jul 2025): We now know we're dealing with a page route,
103
+ // so we create a deferred so that we can signal when we're done determining whether
104
+ // we're returning a response or a react element
105
+ requestInfo.rw.pageRouteResolved = Promise.withResolvers();
106
+ }
101
107
  return await renderPage(requestInfo, WrappedComponent, onError);
102
108
  }
103
109
  else {
@@ -164,6 +170,28 @@ function wrapWithLayouts(Component, layouts = [], requestInfo) {
164
170
  return Wrapped;
165
171
  }, Component);
166
172
  }
173
+ // context(justinvdm, 31 Jul 2025): We need to wrap the handler's that might
174
+ // return react elements, so that it throws the response to bubble it up and
175
+ // break out of react rendering context This way, we're able to return a
176
+ // response from the handler while still staying within react rendering context
177
+ export const wrapHandlerToThrowResponses = (handler) => {
178
+ if (isClientReference(handler) ||
179
+ !isRouteComponent(handler) ||
180
+ Object.prototype.hasOwnProperty.call(handler, "__rwsdk_route_component")) {
181
+ return handler;
182
+ }
183
+ const ComponentWrappedToThrowResponses = async (requestInfo) => {
184
+ const result = await handler(requestInfo);
185
+ if (result instanceof Response) {
186
+ requestInfo.rw.pageRouteResolved?.reject(result);
187
+ throw result;
188
+ }
189
+ requestInfo.rw.pageRouteResolved?.resolve();
190
+ return result;
191
+ };
192
+ ComponentWrappedToThrowResponses.__rwsdk_route_component = true;
193
+ return ComponentWrappedToThrowResponses;
194
+ };
167
195
  export function layout(LayoutComponent, routes) {
168
196
  // Attach layouts directly to route definitions
169
197
  return routes.map((route) => {
@@ -202,9 +230,10 @@ options = {}) {
202
230
  return [documentMiddleware, ...routes];
203
231
  }
204
232
  function isRouteComponent(handler) {
205
- return ((isValidElementType(handler) && handler.toString().includes("jsx")) ||
233
+ return (Object.prototype.hasOwnProperty.call(handler, "__rwsdk_route_component") ||
234
+ (isValidElementType(handler) && handler.toString().includes("jsx")) ||
206
235
  isClientReference(handler));
207
236
  }
208
- export const isClientReference = (Component) => {
209
- return Object.prototype.hasOwnProperty.call(Component, "$$isClientReference");
237
+ export const isClientReference = (value) => {
238
+ return Object.prototype.hasOwnProperty.call(value, "$$isClientReference");
210
239
  };
@@ -41,6 +41,7 @@ export const defineApp = (routes) => {
41
41
  rscPayload: true,
42
42
  ssr: true,
43
43
  databases: new Map(),
44
+ pageRouteResolved: undefined,
44
45
  };
45
46
  const outerRequestInfo = {
46
47
  request,
@@ -58,16 +59,7 @@ export const defineApp = (routes) => {
58
59
  pageElement = _jsx(Page, { ctx: ctx, params: params });
59
60
  }
60
61
  else {
61
- // context(justinvdm, 24 Apr 2025): We need to wrap the page in a component that throws the response to bubble it up and break out of react rendering context
62
- // This way, we're able to return a response from the page component while still staying within react rendering context
63
- const PageWrapper = async () => {
64
- const result = await Page(requestInfo);
65
- if (result instanceof Response) {
66
- throw result;
67
- }
68
- return result;
69
- };
70
- pageElement = _jsx(PageWrapper, {});
62
+ pageElement = _jsx(Page, { ...requestInfo });
71
63
  }
72
64
  if (isSmokeTest) {
73
65
  pageElement = _jsx(SmokeTestWrapper, { children: pageElement });
@@ -147,6 +139,7 @@ export const defineApp = (routes) => {
147
139
  mutableResponse.headers.set(key, value);
148
140
  }
149
141
  }
142
+ await rw.pageRouteResolved?.promise;
150
143
  return mutableResponse;
151
144
  }
152
145
  catch (e) {
@@ -15,9 +15,9 @@ const promptForDeployment = async () => {
15
15
  });
16
16
  return new Promise((resolve) => {
17
17
  // Handle Ctrl+C (SIGINT)
18
- rl.on('SIGINT', () => {
18
+ rl.on("SIGINT", () => {
19
19
  rl.close();
20
- console.log('\nDeployment cancelled.');
20
+ console.log("\nDeployment cancelled.");
21
21
  process.exit(1);
22
22
  });
23
23
  rl.question("Do you want to proceed with deployment? (y/N): ", (answer) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rwsdk",
3
- "version": "0.1.26",
3
+ "version": "0.1.28",
4
4
  "description": "Build fast, server-driven webapps on Cloudflare with SSR, RSC, and realtime",
5
5
  "type": "module",
6
6
  "bin": {