Skip to content

Commit e4478e0

Browse files
feat: Implement popstate support for history navigation
1 parent f80b27e commit e4478e0

File tree

5 files changed

+140
-2
lines changed

5 files changed

+140
-2
lines changed

src/django_unicorn/static/unicorn/js/component.js

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ export class Component {
5454
this.attachedModelEvents = [];
5555

5656
this.init();
57+
this.initHistoryState();
5758
this.refreshEventListeners();
5859
this.initVisibility();
5960
this.initPolling();
@@ -373,6 +374,33 @@ export class Component {
373374
}
374375
}
375376

377+
/**
378+
* Listens for browser `popstate` events (Back/Forward navigation) and restores
379+
* the component state that was stored in the history entry. When a `LocationUpdate`
380+
* is used, the component state is saved in `history.pushState`; this method
381+
* retrieves that state and triggers `$refresh` so the server re-renders the
382+
* component with the restored data.
383+
*/
384+
initHistoryState() {
385+
this.window.addEventListener("popstate", (event) => {
386+
if (
387+
event.state &&
388+
event.state.unicorn &&
389+
event.state.unicorn.componentId === this.id
390+
) {
391+
// Merge the stored state back into the component data
392+
Object.assign(this.data, event.state.unicorn.data);
393+
394+
// Ask the server to re-render with the restored data
395+
this.callMethod("$refresh", 0, null, (err) => {
396+
if (err) {
397+
console.error(err);
398+
}
399+
});
400+
}
401+
});
402+
}
403+
376404
/**
377405
* Starts polling and handles stopping the polling if there is an error.
378406
*/

src/django_unicorn/static/unicorn/js/messageSender.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,12 @@ export function send(component, callback) {
108108
}
109109

110110
component.window.history.pushState(
111-
{},
111+
{
112+
unicorn: {
113+
componentId: component.id,
114+
data: component.data,
115+
},
116+
},
112117
"",
113118
responseJson.redirect.url
114119
);
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import test from "ava";
2+
import { getComponent } from "../utils.js";
3+
4+
const html = `
5+
<input type="hidden" name="csrfmiddlewaretoken" value="asdf">
6+
<div unicorn:id="5jypjiyb" unicorn:name="text-inputs" unicorn:checksum="GXzew3Km">
7+
<input unicorn:model='name'></input>
8+
<button unicorn:click='test()'><span id="clicker">Click</span></button>
9+
</div>
10+
`;
11+
12+
test("popstate listener is registered on window", (t) => {
13+
const component = getComponent(html);
14+
15+
// The initHistoryState() call should have registered a popstate listener
16+
t.truthy(component._windowEventListeners["popstate"]);
17+
t.is(component._windowEventListeners["popstate"].length, 1);
18+
});
19+
20+
test("popstate ignores events with no unicorn state", (t) => {
21+
const component = getComponent(html);
22+
23+
// Fire a popstate event with no state — should not trigger any refresh
24+
const listeners = component._windowEventListeners["popstate"];
25+
t.truthy(listeners);
26+
27+
// actionQueue should be empty before and after
28+
t.is(component.actionQueue.length, 0);
29+
30+
listeners[0]({ state: null });
31+
t.is(component.actionQueue.length, 0);
32+
});
33+
34+
test("popstate ignores events with non-matching component id", (t) => {
35+
const component = getComponent(html);
36+
const listeners = component._windowEventListeners["popstate"];
37+
38+
t.is(component.actionQueue.length, 0);
39+
40+
// Fire with a different componentId — should be ignored
41+
listeners[0]({
42+
state: {
43+
unicorn: {
44+
componentId: "different-id",
45+
data: { name: "OldValue" },
46+
},
47+
},
48+
});
49+
50+
t.is(component.actionQueue.length, 0);
51+
});
52+
53+
test("popstate restores data and triggers refresh for matching component", (t) => {
54+
const component = getComponent(html);
55+
const listeners = component._windowEventListeners["popstate"];
56+
57+
// Spy on callMethod so we can verify $refresh is called without needing
58+
// a working HTTP endpoint
59+
const calledMethods = [];
60+
component.callMethod = (methodName) => {
61+
calledMethods.push(methodName);
62+
};
63+
64+
const previousData = { name: "PreviousName" };
65+
66+
// Fire popstate with a matching componentId and stored previous state
67+
listeners[0]({
68+
state: {
69+
unicorn: {
70+
componentId: component.id,
71+
data: previousData,
72+
},
73+
},
74+
});
75+
76+
// The stored data should have been merged back into the component
77+
t.is(component.data.name, "PreviousName");
78+
79+
// $refresh should have been called to trigger a server re-render
80+
t.is(calledMethods.length, 1);
81+
t.is(calledMethods[0], "$refresh");
82+
});

tests/js/component/messageSender.test.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,13 @@ test("call_method refresh redirect", async (t) => {
8080
t.true(err === null);
8181
t.is(component.window.history.get(), "/test/text-inputs?some=query");
8282
t.is(component.window.document.title, "new title");
83+
84+
// The history state should contain the component id and data
85+
const state = component.window.history.getState();
86+
t.truthy(state.unicorn);
87+
t.is(state.unicorn.componentId, component.id);
88+
t.deepEqual(state.unicorn.data, component.data);
89+
8390
fetchMock.reset();
8491
resolve();
8592
});

tests/js/utils.js

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,13 +103,20 @@ export function getComponent(html, id, name, data) {
103103
data = { name: "World" };
104104
}
105105

106-
const mockHistory = { urls: [] };
106+
const mockHistory = { urls: [], states: [] };
107107
mockHistory.pushState = (state, title, url) => {
108108
mockHistory.urls.push(url);
109+
mockHistory.states.push(state);
109110
};
110111
mockHistory.get = () => {
111112
return mockHistory.urls[0];
112113
};
114+
mockHistory.getState = () => {
115+
return mockHistory.states[0];
116+
};
117+
118+
// Track registered event listeners so tests can fire popstate manually
119+
const windowEventListeners = {};
113120

114121
const component = new Component({
115122
id,
@@ -123,9 +130,18 @@ export function getComponent(html, id, name, data) {
123130
document: { title: "" },
124131
history: mockHistory,
125132
location: { href: "" },
133+
addEventListener: (type, handler) => {
134+
if (!windowEventListeners[type]) {
135+
windowEventListeners[type] = [];
136+
}
137+
windowEventListeners[type].push(handler);
138+
},
126139
},
127140
});
128141

142+
// Expose so tests can trigger window events (e.g. popstate)
143+
component._windowEventListeners = windowEventListeners;
144+
129145
// Set the document explicitly for unit test purposes
130146
component.document = getDocument(html);
131147

0 commit comments

Comments
 (0)