Skip to content

Commit 39fd3a0

Browse files
committed
fix #231
1 parent c5930dd commit 39fd3a0

7 files changed

Lines changed: 331 additions & 117 deletions

File tree

src/js/src/client.ts

Lines changed: 51 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,58 @@
11
import {
22
BaseReactPyClient,
3-
type GenericReactPyClientProps,
43
type ReactPyClientInterface,
54
type ReactPyModule,
6-
type ReactPyUrls,
75
} from "@reactpy/client";
8-
import { createReconnectingWebSocket } from "./websocket";
6+
import { PageClient } from "./pageClient";
7+
import type { ComponentConfig } from "./mount";
8+
9+
export type ReactPyDjangoClientProps = {
10+
rootId: string;
11+
pageClient: PageClient;
12+
mountElement: HTMLElement;
13+
jsModulesPath: string;
14+
componentConfig: ComponentConfig;
15+
};
916

1017
export class ReactPyDjangoClient
1118
extends BaseReactPyClient
1219
implements ReactPyClientInterface
1320
{
14-
urls: ReactPyUrls;
15-
socket: { current?: WebSocket };
16-
mountElement: HTMLElement;
17-
prerenderElement: HTMLElement | null = null;
18-
offlineElement: HTMLElement | null = null;
19-
private readonly messageQueue: any[] = [];
21+
readonly rootId: string;
22+
readonly pageClient: PageClient;
23+
readonly mountElement: HTMLElement;
24+
readonly prerenderElement: HTMLElement | null = null;
25+
readonly offlineElement: HTMLElement | null = null;
26+
readonly jsModulesPath: string;
27+
private readonly componentConfig: ComponentConfig;
2028

21-
constructor(props: GenericReactPyClientProps) {
29+
constructor(props: ReactPyDjangoClientProps) {
2230
super();
2331

24-
this.urls = props.urls;
32+
this.rootId = props.rootId;
33+
this.pageClient = props.pageClient;
2534
this.mountElement = props.mountElement;
35+
this.jsModulesPath = props.jsModulesPath;
36+
this.componentConfig = props.componentConfig;
37+
2638
this.prerenderElement = document.getElementById(
2739
props.mountElement.id + "-prerender",
2840
);
2941
this.offlineElement = document.getElementById(
3042
props.mountElement.id + "-offline",
3143
);
3244

33-
this.socket = createReconnectingWebSocket({
34-
url: this.urls.componentUrl,
35-
readyPromise: this.ready,
36-
...props.reconnectOptions,
37-
// onMessage: Use standard ReactPy message routing
38-
onMessage: async ({ data }) => this.handleIncoming(JSON.parse(data)),
39-
// onClose: If offlineElement exists, show it and hide the mountElement/prerenderElement
45+
// Register with the shared page client for message routing
46+
this.pageClient.registerComponent(this.rootId, {
47+
handleIncoming: (message: any) => this.handleIncoming(message),
48+
onOpen: () => {
49+
// Re-mount the component when the WebSocket reconnects
50+
this.sendMountMessage();
51+
if (this.offlineElement && this.mountElement) {
52+
this.offlineElement.hidden = true;
53+
this.mountElement.hidden = false;
54+
}
55+
},
4056
onClose: () => {
4157
if (this.prerenderElement) {
4258
this.prerenderElement.remove();
@@ -47,28 +63,29 @@ export class ReactPyDjangoClient
4763
this.offlineElement.hidden = false;
4864
}
4965
},
50-
// onOpen: If offlineElement exists, hide it and show the mountElement
51-
onOpen: () => {
52-
if (this.offlineElement && this.mountElement) {
53-
this.offlineElement.hidden = true;
54-
this.mountElement.hidden = false;
55-
}
56-
},
66+
});
67+
}
68+
69+
/** Send a mount-component message to the server so it constructs this component. */
70+
sendMountMessage(): void {
71+
this.pageClient.sendMessage(this.rootId, {
72+
type: "mount-component",
73+
rootId: this.rootId,
74+
dottedPath: this.componentConfig.dottedPath,
75+
componentUuid: this.componentConfig.componentUuid,
76+
hasArgs: Boolean(this.componentConfig.hasArgs),
5777
});
5878
}
5979

6080
sendMessage(message: any): void {
61-
if (
62-
this.socket.current &&
63-
this.socket.current.readyState === WebSocket.OPEN
64-
) {
65-
this.socket.current.send(JSON.stringify(message));
66-
} else {
67-
this.messageQueue.push(message);
68-
}
81+
this.pageClient.sendMessage(this.rootId, message);
6982
}
7083

7184
loadModule(moduleName: string): Promise<ReactPyModule> {
72-
return import(`${this.urls.jsModulesPath}${moduleName}`);
85+
return import(`${this.jsModulesPath}${moduleName}`);
86+
}
87+
88+
destroy(): void {
89+
this.pageClient.unregisterComponent(this.rootId);
7390
}
7491
}

src/js/src/mount.tsx

Lines changed: 34 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,51 +1,66 @@
11
import { ReactPyDjangoClient } from "./client";
2+
import { getPageClient } from "./pageClient";
23
import { Layout, React } from "@reactpy/client";
34

5+
export type ComponentConfig = {
6+
dottedPath: string;
7+
componentUuid: string;
8+
hasArgs: number;
9+
};
10+
411
export function mountComponent(
512
mountElement: HTMLElement,
613
host: string,
714
urlPrefix: string,
8-
componentPath: string,
15+
componentConfig: ComponentConfig,
916
resolvedJsModulesPath: string,
1017
reconnectInterval: number,
1118
reconnectMaxInterval: number,
1219
reconnectMaxRetries: number,
1320
reconnectBackoffMultiplier: number,
1421
) {
15-
// WebSocket route for component rendering
22+
// Shared WebSocket route per page
1623
const wsProtocol = `ws${window.location.protocol === "https:" ? "s" : ""}:`;
1724
const wsOrigin = host
1825
? `${wsProtocol}//${host}`
1926
: `${wsProtocol}//${window.location.host}`;
20-
const componentUrl = new URL(`${wsOrigin}/${urlPrefix}/${componentPath}`);
27+
const wsUrl = new URL(`${wsOrigin}/${urlPrefix}/`);
2128

2229
// Embed the initial HTTP path into the WebSocket URL
23-
componentUrl.searchParams.append("path", window.location.pathname);
30+
wsUrl.searchParams.set("path", window.location.pathname);
2431
if (window.location.search) {
25-
componentUrl.searchParams.append("qs", window.location.search);
32+
wsUrl.searchParams.set("qs", window.location.search);
2633
}
2734

2835
// HTTP route for JavaScript modules
2936
const httpProtocol = window.location.protocol;
3037
const httpOrigin: string = host
3138
? `${httpProtocol}//${host}`
3239
: `${httpProtocol}//${window.location.host}`;
33-
const jsModulesPath: string =
34-
resolvedJsModulesPath || `${urlPrefix}/web_module/`;
40+
const jsModulesPath: string = resolvedJsModulesPath
41+
? `${httpOrigin}${resolvedJsModulesPath.startsWith("/") ? "" : "/"}${resolvedJsModulesPath}`
42+
: `${httpOrigin}/${urlPrefix}/web_module/`;
3543

36-
// Configure a new ReactPy client
37-
const client = new ReactPyDjangoClient({
38-
urls: {
39-
componentUrl: componentUrl,
40-
jsModulesPath: `${httpOrigin}${jsModulesPath.startsWith("/") ? "" : "/"}${jsModulesPath}`,
41-
},
42-
reconnectOptions: {
44+
// Get or create the shared page client (one per unique WS URL)
45+
const pageClient = getPageClient(
46+
wsUrl,
47+
{
4348
interval: reconnectInterval,
4449
maxInterval: reconnectMaxInterval,
4550
maxRetries: reconnectMaxRetries,
4651
backoffMultiplier: reconnectBackoffMultiplier,
4752
},
48-
mountElement: mountElement,
53+
jsModulesPath,
54+
);
55+
56+
// Create a per-component client that shares the page WebSocket
57+
const rootId = componentConfig.componentUuid;
58+
const client = new ReactPyDjangoClient({
59+
rootId,
60+
pageClient,
61+
mountElement,
62+
jsModulesPath,
63+
componentConfig,
4964
});
5065

5166
// Replace the prerender element with the real element on the first layout update
@@ -58,6 +73,10 @@ export function mountComponent(
5873
});
5974
}
6075

76+
// The mount-component message will be sent automatically when the shared WebSocket
77+
// connects (via the onOpen callback in the ReactPyDjangoClient constructor).
78+
// On reconnect the same callback handles re-mounting.
79+
6180
// Start rendering the component
6281
if (client.mountElement) {
6382
React.render(<Layout client={client} />, client.mountElement);

src/js/src/pageClient.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { type ReactPyModule } from "@reactpy/client";
2+
import { createReconnectingWebSocket } from "./websocket";
3+
4+
type ComponentRecord = {
5+
handleIncoming: (message: any) => void;
6+
onOpen?: () => void;
7+
onClose?: () => void;
8+
};
9+
10+
const pageClients = new Map<string, PageClient>();
11+
12+
export function getPageClient(
13+
wsUrl: URL,
14+
reconnectOptions: {
15+
interval: number;
16+
maxInterval: number;
17+
maxRetries: number;
18+
backoffMultiplier: number;
19+
},
20+
jsModulesPath: string,
21+
): PageClient {
22+
const key = `${wsUrl.origin}${wsUrl.pathname}`;
23+
if (!pageClients.has(key)) {
24+
pageClients.set(
25+
key,
26+
new PageClient(wsUrl, reconnectOptions, jsModulesPath),
27+
);
28+
}
29+
return pageClients.get(key)!;
30+
}
31+
32+
export class PageClient {
33+
private components: Map<string, ComponentRecord> = new Map();
34+
socket: { current?: WebSocket } = {};
35+
private readonly messageQueue: any[] = [];
36+
private readonly jsModulesPath: string;
37+
38+
constructor(
39+
private wsUrl: URL,
40+
reconnectOptions: {
41+
interval: number;
42+
maxInterval: number;
43+
maxRetries: number;
44+
backoffMultiplier: number;
45+
},
46+
jsModulesPath: string,
47+
) {
48+
this.jsModulesPath = jsModulesPath;
49+
50+
// Immediately-resolved promise — the shared socket connects right away
51+
const immediatePromise = Promise.resolve();
52+
53+
this.socket = createReconnectingWebSocket({
54+
url: wsUrl,
55+
readyPromise: immediatePromise,
56+
...reconnectOptions,
57+
onMessage: async ({ data }) => this.handleIncoming(JSON.parse(data)),
58+
onOpen: () => {
59+
// Notify all registered components that the connection is back
60+
for (const [, record] of this.components) {
61+
if (record.onOpen) record.onOpen();
62+
}
63+
// Drain queued messages
64+
while (this.messageQueue.length > 0) {
65+
this.sendRaw(this.messageQueue.shift());
66+
}
67+
},
68+
onClose: () => {
69+
for (const [, record] of this.components) {
70+
if (record.onClose) record.onClose();
71+
}
72+
},
73+
});
74+
}
75+
76+
registerComponent(rootId: string, record: ComponentRecord): void {
77+
this.components.set(rootId, record);
78+
}
79+
80+
unregisterComponent(rootId: string): void {
81+
this.components.delete(rootId);
82+
}
83+
84+
/** Tag an outgoing message with rootId and send it through the shared socket. */
85+
sendMessage(rootId: string, message: any): void {
86+
message.rootId = rootId;
87+
this.sendRaw(message);
88+
}
89+
90+
private sendRaw(message: any): void {
91+
if (
92+
this.socket.current &&
93+
this.socket.current.readyState === WebSocket.OPEN
94+
) {
95+
this.socket.current.send(JSON.stringify(message));
96+
} else {
97+
this.messageQueue.push(message);
98+
}
99+
}
100+
101+
/** Route an incoming message to the correct component by rootId. */
102+
private handleIncoming(message: any): void {
103+
const { rootId } = message;
104+
if (rootId && this.components.has(rootId)) {
105+
// Strip rootId before forwarding to the component's handler
106+
const componentMessage = { ...message };
107+
delete componentMessage.rootId;
108+
this.components.get(rootId)!.handleIncoming(componentMessage);
109+
}
110+
}
111+
112+
loadModule(moduleName: string): Promise<ReactPyModule> {
113+
return import(`${this.jsModulesPath}${moduleName}`);
114+
}
115+
}

src/reactpy_django/templates/reactpy/component.html

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,26 @@
1-
{% load static %}
2-
3-
{% if reactpy_failure and django_debug %}
1+
{% load static %} {% if reactpy_failure and django_debug %}
42
<pre>{% firstof reactpy_error "UnknownError" %}: "{% firstof reactpy_dotted_path "UnknownPath" %}"</pre>
3+
{% endif %} {% if not reactpy_failure %} {% if reactpy_class %}
4+
<div id="{{reactpy_uuid}}" class="{{reactpy_class}}"></div>
5+
{% endif %} {% if not reactpy_class %}
6+
<div id="{{reactpy_uuid}}"></div>
7+
{% endif %} {% if reactpy_prerender_html %}
8+
<div id="{{reactpy_uuid}}-prerender">{{reactpy_prerender_html|safe}}</div>
9+
{% endif %} {% if reactpy_offline_html %}
10+
<div id="{{reactpy_uuid}}-offline" hidden>{{reactpy_offline_html|safe}}</div>
511
{% endif %}
6-
7-
{% if not reactpy_failure %}
8-
{% if reactpy_class %}<div id="{{reactpy_uuid}}" class="{{reactpy_class}}"></div>{% endif %}
9-
{% if not reactpy_class %}<div id="{{reactpy_uuid}}"></div>{% endif %}
10-
{% if reactpy_prerender_html %}<div id="{{reactpy_uuid}}-prerender">{{reactpy_prerender_html|safe}}</div>{% endif %}
11-
{% if reactpy_offline_html %}<div id="{{reactpy_uuid}}-offline" hidden>{{reactpy_offline_html|safe}}</div>{% endif %}
1212
<script type="module" crossorigin="anonymous">
1313
import { mountComponent } from "{% static 'reactpy_django/index.js' %}";
1414
const mountElement = document.getElementById("{{reactpy_uuid}}");
1515
mountComponent(
1616
mountElement,
1717
"{{reactpy_host}}",
1818
"{{reactpy_url_prefix}}",
19-
"{{reactpy_component_path}}",
19+
{
20+
dottedPath: "{{reactpy_dotted_path}}",
21+
componentUuid: "{{reactpy_component_uuid}}",
22+
hasArgs: {{reactpy_has_args}},
23+
},
2024
"{{reactpy_resolved_web_modules_path}}",
2125
Number("{{reactpy_reconnect_interval}}"),
2226
Number("{{reactpy_reconnect_max_interval}}"),

src/reactpy_django/templatetags/reactpy.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,9 @@ def component(
177177
"reactpy_uuid": uuid,
178178
"reactpy_host": host or perceived_host,
179179
"reactpy_url_prefix": reactpy_config.REACTPY_URL_PREFIX,
180-
"reactpy_component_path": f"{dotted_path}/{uuid}/{int(has_args)}/",
180+
"reactpy_dotted_path": dotted_path,
181+
"reactpy_component_uuid": uuid,
182+
"reactpy_has_args": int(has_args),
181183
"reactpy_resolved_web_modules_path": f"/{RESOLVED_WEB_MODULES_PATH.strip('/')}/",
182184
"reactpy_reconnect_interval": reactpy_config.REACTPY_RECONNECT_INTERVAL,
183185
"reactpy_reconnect_max_interval": reactpy_config.REACTPY_RECONNECT_MAX_INTERVAL,

0 commit comments

Comments
 (0)