View Javadoc
1   /*
2    * Copyright (c) 2002-2026 Gargoyle Software Inc.
3    *
4    * Licensed under the Apache License, Version 2.0 (the "License");
5    * you may not use this file except in compliance with the License.
6    * You may obtain a copy of the License at
7    * https://www.apache.org/licenses/LICENSE-2.0
8    *
9    * Unless required by applicable law or agreed to in writing, software
10   * distributed under the License is distributed on an "AS IS" BASIS,
11   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12   * See the License for the specific language governing permissions and
13   * limitations under the License.
14   */
15  package org.htmlunit;
16  
17  import java.io.IOException;
18  import java.io.ObjectInputStream;
19  import java.io.Serializable;
20  import java.lang.ref.SoftReference;
21  import java.net.URL;
22  import java.util.ArrayList;
23  import java.util.List;
24  
25  import org.htmlunit.javascript.host.Window;
26  import org.htmlunit.javascript.host.event.Event;
27  import org.htmlunit.javascript.host.event.PopStateEvent;
28  import org.htmlunit.util.HeaderUtils;
29  import org.htmlunit.util.UrlUtils;
30  
31  /**
32   * Representation of the navigation history of a single window.
33   *
34   * @author Daniel Gredler
35   * @author Ahmed Ashour
36   * @author Adam Afeltowicz
37   * @author Ronald Brill
38   * @author Madis Pärn
39   */
40  public class History implements Serializable {
41  
42      /** The window to which this navigation history belongs. */
43      private final WebWindow window_;
44  
45      /**
46       * Whether or not to ignore calls to {@link #addPage(Page)}; this is a bit hackish (we should probably be using
47       * explicit boolean parameters in the various methods that load new pages), but it does the job for now -- without
48       * any new API cruft.
49       */
50      private transient ThreadLocal<Boolean> ignoreNewPages_;
51  
52      /**
53       * The {@link History.HistoryEntry}s in this navigation history.
54       */
55      private final List<HistoryEntry> entries_ = new ArrayList<>();
56  
57      /** The current index within the list of pages which make up this navigation history. */
58      private int index_ = -1;
59  
60      /**
61       * The single entry in the history.
62       */
63      private static final class HistoryEntry implements Serializable {
64          private transient SoftReference<Page> page_;
65          private final WebRequest webRequest_;
66          private Object state_;
67  
68          HistoryEntry(final Page page) {
69  
70              // verify cache-control header values before storing
71              if (HeaderUtils.containsNoStore(page.getWebResponse())) {
72                  page_ = null;
73              }
74              else {
75                  page_ = new SoftReference<>(page);
76              }
77  
78              final WebRequest request = page.getWebResponse().getWebRequest();
79              webRequest_ = new WebRequest(request.getUrl(), request.getHttpMethod());
80              webRequest_.setRequestParameters(request.getRequestParameters());
81          }
82  
83          Page getPage() {
84              if (page_ == null) {
85                  return null;
86              }
87              return page_.get();
88          }
89  
90          void clearPage() {
91              page_ = null;
92          }
93  
94          WebRequest getWebRequest() {
95              return webRequest_;
96          }
97  
98          URL getUrl() {
99              return webRequest_.getUrl();
100         }
101 
102         void setUrl(final URL url, final Page page) {
103             if (url != null) {
104                 WebWindow webWindow = null;
105                 if (page != null) {
106                     webWindow = page.getEnclosingWindow();
107                 }
108 
109                 final URL encoded = UrlUtils.encodeUrl(url, webRequest_.getCharset());
110                 webRequest_.setUrl(encoded);
111                 if (page != null) {
112                     page.getWebResponse().getWebRequest().setUrl(encoded);
113                     if (webWindow != null) {
114                         final Window win = webWindow.getScriptableObject();
115                         if (win != null) {
116                             win.getLocation().setHash(null, encoded.getRef(), false);
117                         }
118                     }
119                 }
120             }
121         }
122 
123         /**
124          * Returns the state object.
125          *
126          * @return the state object
127          */
128         Object getState() {
129             return state_;
130         }
131 
132         /**
133          * Sets the state object.
134          * @param state the state object to use
135          */
136         void setState(final Object state) {
137             state_ = state;
138         }
139     }
140 
141     /**
142      * Creates a new navigation history for the specified window.
143      * @param window the window which owns the new navigation history
144      */
145     public History(final WebWindow window) {
146         window_ = window;
147         initTransientFields();
148     }
149 
150     /**
151      * Initializes the transient fields.
152      */
153     private void initTransientFields() {
154         ignoreNewPages_ = new ThreadLocal<>();
155     }
156 
157     /**
158      * Returns the length of the navigation history.
159      * @return the length of the navigation history
160      */
161     public int getLength() {
162         return entries_.size();
163     }
164 
165     /**
166      * Returns the current (zero-based) index within the navigation history.
167      * @return the current (zero-based) index within the navigation history
168      */
169     public int getIndex() {
170         return index_;
171     }
172 
173     /**
174      * Returns the URL at the specified index in the navigation history, or {@code null} if the index is not valid.
175      * @param index the index of the URL to be returned
176      * @return the URL at the specified index in the navigation history, or {@code null} if the index is not valid
177      */
178     public URL getUrl(final int index) {
179         if (index >= 0 && index < entries_.size()) {
180             return UrlUtils.toUrlSafe(entries_.get(index).getUrl().toExternalForm());
181         }
182         return null;
183     }
184 
185     /**
186      * Goes back one step in the navigation history, if possible.
187      * @return this navigation history, after going back one step
188      * @throws IOException in case of error
189      */
190     public History back() throws IOException {
191         if (index_ > 0) {
192             index_--;
193             goToUrlAtCurrentIndex();
194         }
195         return this;
196     }
197 
198     /**
199      * Goes forward one step in the navigation history, if possible.
200      * @return this navigation history, after going forward one step
201      * @throws IOException in case of error
202      */
203     public History forward() throws IOException {
204         if (index_ < entries_.size() - 1) {
205             index_++;
206             goToUrlAtCurrentIndex();
207         }
208         return this;
209     }
210 
211     /**
212      * Goes forward or backwards in the navigation history, according to whether the specified relative index
213      * is positive or negative. If the specified index is <code>0</code>, this method reloads the current page.
214      * @param relativeIndex the index to move to, relative to the current index
215      * @return this navigation history, after going forwards or backwards the specified number of steps
216      * @throws IOException in case of error
217      */
218     public History go(final int relativeIndex) throws IOException {
219         final int i = index_ + relativeIndex;
220         if (i < entries_.size() && i >= 0) {
221             index_ = i;
222             goToUrlAtCurrentIndex();
223         }
224         return this;
225     }
226 
227     /**
228      * {@inheritDoc}
229      */
230     @Override
231     public String toString() {
232         return entries_.toString();
233     }
234 
235     /**
236      * Removes the current URL from the history.
237      */
238     public void removeCurrent() {
239         if (index_ >= 0 && index_ < entries_.size()) {
240             entries_.remove(index_);
241             if (index_ > 0) {
242                 index_--;
243             }
244         }
245     }
246 
247     /**
248      * Adds a new page to the navigation history.
249      * @param page the page to add to the navigation history
250      * @return the created history entry
251      */
252     protected HistoryEntry addPage(final Page page) {
253         final Boolean ignoreNewPages = ignoreNewPages_.get();
254         if (ignoreNewPages != null && ignoreNewPages.booleanValue()) {
255             return null;
256         }
257 
258         final int sizeLimit = window_.getWebClient().getOptions().getHistorySizeLimit();
259         if (sizeLimit <= 0) {
260             entries_.clear();
261             index_ = -1;
262             return null;
263         }
264 
265         index_++;
266         while (entries_.size() > index_) {
267             entries_.remove(index_);
268         }
269         while (entries_.size() >= sizeLimit) {
270             entries_.remove(0);
271             index_--;
272         }
273 
274         final HistoryEntry entry = new HistoryEntry(page);
275         entries_.add(entry);
276 
277         final int cacheLimit = Math.max(window_.getWebClient().getOptions().getHistoryPageCacheLimit(), 0);
278         if (entries_.size() > cacheLimit) {
279             entries_.get(entries_.size() - cacheLimit - 1).clearPage();
280         }
281 
282         return entry;
283     }
284 
285     /**
286      * Loads the URL at the current index into the window to which this navigation history belongs.
287      * @throws IOException if an IO error occurs
288      */
289     private void goToUrlAtCurrentIndex() throws IOException {
290         final Boolean old = ignoreNewPages_.get();
291         ignoreNewPages_.set(Boolean.TRUE);
292         try {
293 
294             final HistoryEntry entry = entries_.get(index_);
295 
296             final Page page = entry.getPage();
297             if (page == null) {
298                 window_.getWebClient().getPage(window_, entry.getWebRequest(), false);
299             }
300             else {
301                 window_.setEnclosedPage(page);
302                 page.getWebResponse().getWebRequest().setUrl(entry.getUrl());
303             }
304 
305             final Window jsWindow = window_.getScriptableObject();
306             if (jsWindow != null && jsWindow.hasEventHandlers("onpopstate")) {
307                 final Event event = new PopStateEvent(jsWindow, Event.TYPE_POPSTATE, entry.getState());
308                 jsWindow.executeEventLocally(event);
309             }
310         }
311         finally {
312             ignoreNewPages_.set(old);
313         }
314     }
315 
316     /**
317      * Allows to change history state and url if provided.
318      *
319      * @param state the new state to use
320      * @param url the new url to use
321      */
322     public void replaceState(final Object state, final URL url) {
323         if (index_ >= 0 && index_ < entries_.size()) {
324             final HistoryEntry entry = entries_.get(index_);
325 
326             Page page = entry.getPage();
327             if (page == null) {
328                 page = window_.getEnclosedPage();
329             }
330 
331             entry.setUrl(url, page);
332             entry.setState(state);
333         }
334     }
335 
336     /**
337      * Allows to change history state and url if provided.
338      *
339      * @param state the new state to use
340      * @param url the new url to use
341      */
342     public void pushState(final Object state, final URL url) {
343         final Page page = window_.getEnclosedPage();
344         final HistoryEntry entry = addPage(page);
345 
346         if (entry != null) {
347             entry.setUrl(url, page);
348             entry.setState(state);
349         }
350     }
351 
352     /**
353      * Returns current state object.
354      *
355      * @return the current state object
356      */
357     public Object getCurrentState() {
358         if (index_ >= 0 && index_ < entries_.size()) {
359             return entries_.get(index_).getState();
360         }
361         return null;
362     }
363 
364     /**
365      * Re-initializes transient fields when an object of this type is deserialized.
366      * @param in the object input stream
367      * @throws IOException if an error occurs
368      * @throws ClassNotFoundException if an error occurs
369      */
370     private void readObject(final ObjectInputStream in) throws IOException, ClassNotFoundException {
371         in.defaultReadObject();
372         initTransientFields();
373     }
374 }