Skip to content

Commit bb50f84

Browse files
committed
refactor(html): use hydrated set for island scroll saves; drop announcePage and view transition
- Export `hydrated` from runtime so the router reads it directly instead of querying `document` for mounted islands before a page swap - Rename `getIslands` → `keyIslands(source)` — takes an explicit iterable; the post-swap restore passes `document.body.querySelectorAll` scoped to the new body - Remove the cross-fade view transition (simpler, no animation on nav) - Remove `announcePage` helper Assisted-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 14f5067 commit bb50f84

3 files changed

Lines changed: 28 additions & 61 deletions

File tree

‎packages/react/src/html/ui/islands/runtime.mjs‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,12 @@ import { h, hydrate, render } from 'preact';
44
import loaders from './loaders.mjs';
55

66
/**
7-
* The islands hydrated so far, so that those a client-side navigation is about
8-
* to discard can be unmounted first.
7+
* The islands hydrated so far. Exported so the router can save their scroll
8+
* positions before a navigation discards them, without querying the document.
99
*
1010
* @type {Set<HTMLElement>}
1111
*/
12-
const hydrated = new Set();
12+
export const hydrated = new Set();
1313

1414
/**
1515
* The components loaded so far, by island name. Even an `import()` of a module

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

Lines changed: 23 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -62,26 +62,26 @@ export const withoutFragment = href => href.split('#')[0];
6262
*
6363
* @param {Document} doc
6464
* @param {string} base - The document's URL, for relative references
65-
* @returns {Array<string>}
65+
* @returns {Array<URL>}
6666
*/
6767
const getAssets = (doc, base) =>
6868
[...doc.querySelectorAll('script[src], link[rel~="stylesheet"][href]')].map(
6969
element =>
7070
new URL(element.getAttribute('src') ?? element.getAttribute('href'), base)
71-
.href
7271
);
7372

7473
/**
75-
* Keys the islands of the document by name and occurrence, so that the same
76-
* island is found again on the next page.
74+
* Keys an iterable of islands by name and occurrence, so that the same island
75+
* is found again on the next page.
7776
*
77+
* @param {Iterable<HTMLElement>} source
7878
* @returns {Map<string, HTMLElement>}
7979
*/
80-
const getIslands = () => {
80+
const keyIslands = source => {
8181
const counts = new Map();
8282

8383
return new Map(
84-
[...document.querySelectorAll('is-land[data-island-name]')].map(island => {
84+
[...source].map(island => {
8585
const name = island.getAttribute('data-island-name');
8686
counts.set(name, (counts.get(name) ?? 0) + 1);
8787

@@ -117,45 +117,11 @@ const updateHead = doc => {
117117
};
118118

119119
/**
120-
* Announces the new page to assistive technology, as loading it would have.
121-
*/
122-
const announcePage = () => {
123-
const region = document.createElement('div');
124-
125-
region.setAttribute('aria-live', 'assertive');
126-
region.setAttribute('aria-atomic', 'true');
127-
region.style.cssText =
128-
'position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap';
129-
130-
document.body.append(region);
131-
132-
// A region filled as it is inserted is not announced
133-
setTimeout(() => (region.textContent = document.title), 100);
134-
};
135-
136-
/**
137-
* Runs a DOM update inside a view transition, where supported and wanted, so
138-
* the old page cross-fades into the new one.
120+
* Runs a DOM update, keeping the call-site uniform for a future transition.
139121
*
140122
* @param {() => void} update
141-
* @returns {Promise<void> | undefined} Settles once the DOM is updated
142123
*/
143-
const transition = update => {
144-
if (
145-
!document.startViewTransition ||
146-
matchMedia('(prefers-reduced-motion: reduce)').matches
147-
) {
148-
return update();
149-
}
150-
151-
const { ready, updateCallbackDone } = document.startViewTransition(update);
152-
153-
// Skipped transitions (the tab is hidden, or another navigation started)
154-
// still update the DOM; only their animation is lost
155-
ready.catch(() => {});
156-
157-
return updateCallbackDone;
158-
};
124+
const transition = update => update();
159125

160126
/**
161127
* Starts handling navigations between the pages of the site, unless the
@@ -165,8 +131,10 @@ const transition = update => {
165131
* @param {object} options
166132
* @param {(root: Node) => void} options.unmount - Unmounts the components
167133
* rendered inside the part of the document that is about to be discarded.
134+
* @param {Set<HTMLElement>} options.islands - The runtime's set of hydrated
135+
* islands; used to save scroll positions before the body is replaced.
168136
*/
169-
export const startRouter = ({ unmount }) => {
137+
export const startRouter = ({ unmount, islands }) => {
170138
const script = document.querySelector('script[data-root]');
171139

172140
if (!('navigation' in window) || !script) {
@@ -177,7 +145,9 @@ export const startRouter = ({ unmount }) => {
177145

178146
// The document's head is never replaced (see `PAGE_HEAD`), so the relative
179147
// URLs in it are resolved while they still point where they did at load
180-
const assets = new Set(getAssets(document, location.href));
148+
const assets = new Set(
149+
getAssets(document, location.href).map(url => url.href)
150+
);
181151

182152
/** @type {Map<string, { page: Promise<Page | null>, expires: number }>} */
183153
const pages = new Map();
@@ -233,7 +203,9 @@ export const startRouter = ({ unmount }) => {
233203
const parsePage = ({ url, html }) => {
234204
const doc = new DOMParser().parseFromString(html, 'text/html');
235205

236-
return getAssets(doc, url).every(asset => assets.has(asset)) ? doc : null;
206+
return getAssets(doc, url).every(asset => assets.has(asset.href))
207+
? doc
208+
: null;
237209
};
238210

239211
/**
@@ -244,7 +216,7 @@ export const startRouter = ({ unmount }) => {
244216
*/
245217
const showPage = (doc, scroll) => {
246218
// The sidebar (like any island that scrolls) stays where it was
247-
const scrolled = [...getIslands()]
219+
const scrolled = [...keyIslands(islands)]
248220
.filter(([, island]) => island.scrollTop || island.scrollLeft)
249221
.map(([key, { scrollLeft, scrollTop }]) => [key, scrollLeft, scrollTop]);
250222

@@ -256,14 +228,15 @@ export const startRouter = ({ unmount }) => {
256228
// from animating again with every page
257229
document.documentElement.setAttribute('data-navigated', '');
258230

259-
const islands = getIslands();
231+
const next = keyIslands(
232+
document.body.querySelectorAll('is-land[data-island-name]')
233+
);
260234

261235
for (const [key, left, top] of scrolled) {
262-
islands.get(key)?.scrollTo({ left, top, behavior: 'instant' });
236+
next.get(key)?.scrollTo({ left, top, behavior: 'instant' });
263237
}
264238

265239
scroll();
266-
announcePage();
267240
};
268241

269242
/**
@@ -329,13 +302,7 @@ export const startRouter = ({ unmount }) => {
329302
return;
330303
}
331304

332-
await transition(() => {
333-
// Another navigation superseded this one while the old page was
334-
// being captured for the transition
335-
if (!event.signal.aborted) {
336-
showPage(doc, () => event.scroll());
337-
}
338-
});
305+
transition(() => showPage(doc, () => event.scroll()));
339306
},
340307
});
341308
});

‎packages/react/src/html/utils/generate.mjs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ export default () => {
158158
),
159159

160160
createImportDeclaration(
161-
'registerIslands, unmountIslands',
161+
'registerIslands, unmountIslands, hydrated',
162162
resolve(ROOT, './ui/islands/runtime.mjs'),
163163
false
164164
),
@@ -178,7 +178,7 @@ export default () => {
178178

179179
// Navigations between pages swap the page in place, so the islands of the
180180
// page being left have to be unmounted rather than simply dropped
181-
'startRouter({ unmount: unmountIslands });',
181+
'startRouter({ unmount: unmountIslands, islands: hydrated });',
182182
].join('\n');
183183

184184
return { buildLibraryProgram, buildPageProgram, clientProgram };

0 commit comments

Comments
 (0)