Skip to content

Commit 2da3404

Browse files
committed
fix(html): free fetched pages when they expire, and count the cache limit in pages
Assisted-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 9418300 commit 2da3404

2 files changed

Lines changed: 47 additions & 18 deletions

File tree

‎packages/react/src/html/ui/constants.mjs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/** How long a hovered link waits before its page is prefetched. */
22
export const ROUTER_HOVER_DELAY = 80;
33

4-
/** How long a fetched page is reused for, whether prefetched or visited. */
4+
/** How long a fetched page is kept for reuse, whether prefetched or visited. */
55
export const ROUTER_PAGE_LIFETIME = 5 * 60 * 1000;
66

77
/** How many fetched pages are kept at once. */

‎packages/react/src/html/ui/router.mjs‎

Lines changed: 46 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -98,9 +98,37 @@ export const startRouter = ({ unmount, islands }) => {
9898
.map(({ href }) => href)
9999
);
100100

101-
/** @type {Map<string, { page: Promise<import('./page.mjs').Page | null>, expires: number }>} */
101+
/**
102+
* @typedef {object} Entry A fetched page, kept for reuse.
103+
* @property {Promise<import('./page.mjs').Page | null>} page
104+
* @property {ReturnType<typeof setTimeout>} [expiry] - Drops the page once
105+
* it is too old to reuse
106+
*/
107+
108+
/**
109+
* The pages fetched lately, by URL, oldest first. A page the host redirected
110+
* is kept under the URL it was fetched from as well.
111+
*
112+
* @type {Map<string, Entry>}
113+
*/
102114
const pages = new Map();
103115

116+
/**
117+
* Drops a page from memory, under every URL it is kept under.
118+
*
119+
* @param {Entry} entry
120+
*/
121+
const forget = entry => {
122+
// A pending expiry would otherwise hold on to the page until it runs
123+
clearTimeout(entry.expiry);
124+
125+
for (const [href, kept] of pages) {
126+
if (kept === entry) {
127+
pages.delete(href);
128+
}
129+
}
130+
};
131+
104132
/**
105133
* Fetches a page, or reuses the copy fetched moments ago.
106134
*
@@ -111,34 +139,35 @@ export const startRouter = ({ unmount, islands }) => {
111139
const loadPage = href => {
112140
const cached = pages.get(href);
113141

114-
if (cached && cached.expires > Date.now()) {
142+
if (cached) {
115143
return cached.page;
116144
}
117145

118-
const page = fetchPage(href);
119-
const entry = { page, expires: Date.now() + ROUTER_PAGE_LIFETIME };
146+
/** @type {Entry} */
147+
const entry = { page: fetchPage(href) };
120148

121-
pages.delete(href);
149+
entry.expiry = setTimeout(forget, ROUTER_PAGE_LIFETIME, entry);
122150
pages.set(href, entry);
123151

124-
// Redirected pages take two entries (below)
125-
while (pages.size > ROUTER_MAX_PAGES) {
126-
pages.delete(pages.keys().next().value);
127-
}
152+
// The limit counts pages, not the URLs they are kept under
153+
const kept = new Set(pages.values());
128154

129-
page.then(result => {
130-
// A failure is not kept, so the next attempt fetches again
131-
if (!result && pages.get(href)?.page === page) {
132-
pages.delete(href);
133-
}
155+
if (kept.size > ROUTER_MAX_PAGES) {
156+
forget(kept.values().next().value);
157+
}
134158

135-
// Following the redirect (see the handler) then needs no second fetch
136-
if (result && result.url !== href) {
159+
entry.page.then(result => {
160+
if (!result) {
161+
// A failure is not kept, so the next attempt fetches again
162+
forget(entry);
163+
} else if (result.url !== href && pages.get(href) === entry) {
164+
// Following the redirect (see the handler) then needs no second fetch
165+
pages.delete(result.url);
137166
pages.set(result.url, entry);
138167
}
139168
});
140169

141-
return page;
170+
return entry.page;
142171
};
143172

144173
/**

0 commit comments

Comments
 (0)