Skip to content

Commit fb67d93

Browse files
Merge branch 'dev' into markdalgleish/update-polyfills-plugin
2 parents 2bda2d3 + b5bfcd1 commit fb67d93

File tree

10 files changed

+174
-15
lines changed

10 files changed

+174
-15
lines changed

.changeset/five-zoos-fix.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@remix-run/dev": patch
3+
---
4+
5+
Fix bug with pathless layout routes beneath nested path segments

.changeset/prefetch-viewport.md

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"remix": minor
3+
"@remix-run/react": minor
4+
---
5+
6+
Add support for `<Link prefetch="viewport">` to prefetch links when they enter the viewport via an [Intersection Observer](https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver)

docs/components/link.md

+2
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,14 @@ In the effort to remove all loading states from your UI, `Link` can automaticall
3030
<Link prefetch="none" />
3131
<Link prefetch="intent" />
3232
<Link prefetch="render" />
33+
<Link prefetch="viewport" />
3334
</>
3435
```
3536

3637
- **"none"** - Default behavior. This will prevent any prefetching from happening. This is recommended when linking to pages that require a user session that the browser won't be able to prefetch anyway.
3738
- **"intent"** - Recommended if you want to prefetch. Fetches when Remix thinks the user intends to visit the link. Right now the behavior is simple: if they hover or focus the link it will prefetch the resources. In the future we hope to make this even smarter. Links with large click areas/padding get a bit of a head start. It is worth noting that when using `prefetch="intent"`, `<link rel="prefetch">` elements will be inserted on hover/focus and removed if the `<Link>` loses hover/focus. Without proper `cache-control` headers on your loaders, this could result in repeated prefetch loads if a user continually hovers on and off a link.
3839
- **"render"** - Fetches when the link is rendered.
40+
- **"viewport"** - Fetches while the link is in the viewport
3941

4042
<docs-error>You may need to use the <code>:last-of-type</code> selector instead of <code>:last-child</code> when styling child elements inside of your links</docs-error>
4143

docs/components/nav-link.md

+2-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ toc: false
55

66
# `<NavLink>`
77

8-
A `<NavLink>` is a special kind of `<Link>` that knows whether or not it is "active" or "pending". This is useful when building a navigation menu, such as a breadcrumb or a set of tabs where you'd like to show which of them is currently selected. It also provides useful context for assistive technology like screen readers.
8+
A `<NavLink>` is a special kind of [`<Link>`][link] that knows whether or not it is "active" or "pending". This is useful when building a navigation menu, such as a breadcrumb or a set of tabs where you'd like to show which of them is currently selected. It also provides useful context for assistive technology like screen readers.
99

1010
```tsx
1111
import { NavLink } from "@remix-run/react";
@@ -122,3 +122,4 @@ Adding the `caseSensitive` prop changes the matching logic to make it case sensi
122122
When a `NavLink` is active it will automatically apply `<a aria-current="page">` to the underlying anchor tag. See [aria-current][aria-current] on MDN.
123123

124124
[aria-current]: https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-current
125+
[link]: ./link.md

docs/other-api/dev-v2.md

+17
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,22 @@ The dev server will:
3737
3. Restart your app server whenever rebuilds succeed
3838
4. Send code updates to the browser via Live Reload and HMR + Hot Data Revalidation
3939

40+
<docs-info>
41+
42+
What is "Hot Data Revalidation"?
43+
44+
Like HMR, HDR is a way of hot updating your app without needing to refresh the page.
45+
That way you can keep your app state as your edits are applied in your app.
46+
HMR handles client-side code updates like when you change the components, markup, or styles in your app.
47+
Likewise, HDR handles server-side code updates.
48+
49+
That means any time your change a `loader` on your current page (or any code that your `loader` depends on), Remix will re-fetch data from your changed loader.
50+
That way your app is _always_ up-to-date with the latest code changes, client-side or server-side.
51+
52+
To learn more about how HMR and HDR work together, check out [Pedro's talk at Remix Conf 2023][legendary-dx].
53+
54+
</docs-info>
55+
4056
### With `remix-serve`
4157

4258
Enable the v2 dev server:
@@ -342,6 +358,7 @@ That way the dev server can detect loader changes on rebuilds.
342358

343359
While the initial build slowdown is inherently a cost for HDR, we plan to optimize rebuilds so that there is no perceivable slowdown for HDR rebuilds.
344360

361+
[legendary-dx]: https://www.youtube.com/watch?v=79M4vYZi-po
345362
[templates]: https://github.com/remix-run/remix/tree/main/templates
346363
[watch-paths]: https://remix.run/docs/en/1.17.1/file-conventions/remix-config#watchpaths
347364
[jenseng-code]: https://github.com/jenseng/abuse-the-platform/blob/main/app/utils/singleton.ts

docs/pages/v2.md

+4-3
Original file line numberDiff line numberDiff line change
@@ -709,7 +709,7 @@ For configuration options, see the [`remix dev` docs][v2-dev-config].
709709

710710
### `remix-serve`
711711

712-
Enable the v2 dev server:
712+
If you are using the Remix App Server (`remix-serve`), enable the v2 dev server:
713713

714714
```js filename=remix.config.js
715715
module.exports = {
@@ -721,9 +721,10 @@ module.exports = {
721721

722722
That's it!
723723

724-
### custom app server
724+
### Custom app server
725725

726-
Check out our [templates][templates] for examples of how to integrate with `v2_dev`
726+
If you are using your own app server (`server.js`),
727+
then check out our [templates][templates] for examples of how to integrate with `v2_dev`
727728
or follow these steps:
728729

729730
1. Enable the v2 dev server:

integration/prefetch-test.ts

+71
Original file line numberDiff line numberDiff line change
@@ -271,3 +271,74 @@ test.describe("prefetch=intent (focus)", () => {
271271
expect(await page.locator("#nav link").count()).toBe(1);
272272
});
273273
});
274+
275+
test.describe("prefetch=viewport", () => {
276+
let fixture: Fixture;
277+
let appFixture: AppFixture;
278+
279+
test.beforeAll(async () => {
280+
fixture = await createFixture({
281+
config: {
282+
future: { v2_routeConvention: true },
283+
},
284+
files: {
285+
"app/routes/_index.jsx": js`
286+
import { Link } from "@remix-run/react";
287+
288+
export default function Component() {
289+
return (
290+
<>
291+
<h1>Index Page - Scroll Down</h1>
292+
<div style={{ marginTop: "150vh" }}>
293+
<Link to="/test" prefetch="viewport">Click me!</Link>
294+
</div>
295+
</>
296+
);
297+
}
298+
`,
299+
300+
"app/routes/test.jsx": js`
301+
export function loader() {
302+
return null;
303+
}
304+
export default function Component() {
305+
return <h1>Test Page</h1>;
306+
}
307+
`,
308+
},
309+
});
310+
311+
// This creates an interactive app using puppeteer.
312+
appFixture = await createAppFixture(fixture);
313+
});
314+
315+
test.afterAll(() => {
316+
appFixture.close();
317+
});
318+
319+
test("should prefetch when the link enters the viewport", async ({
320+
page,
321+
}) => {
322+
let app = new PlaywrightFixture(appFixture, page);
323+
await app.goto("/");
324+
325+
// No preloads to start
326+
await expect(page.locator("div link")).toHaveCount(0);
327+
328+
// Preloads render on scroll down
329+
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
330+
331+
await page.waitForSelector(
332+
"div link[rel='prefetch'][as='fetch'][href='/test?_data=routes%2Ftest']",
333+
{ state: "attached" }
334+
);
335+
await page.waitForSelector(
336+
"div link[rel='modulepreload'][href^='/build/routes/test-']",
337+
{ state: "attached" }
338+
);
339+
340+
// Preloads removed on scroll up
341+
await page.evaluate(() => window.scrollTo(0, 0));
342+
await expect(page.locator("div link")).toHaveCount(0);
343+
});
344+
});

packages/remix-dev/__tests__/flat-routes-test.ts

+25
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,31 @@ describe("flatRoutes", () => {
272272
path: ":id",
273273
},
274274
],
275+
[
276+
"routes/app._pathless.tsx",
277+
{
278+
id: "routes/app._pathless",
279+
parentId: "routes/app",
280+
path: undefined,
281+
},
282+
],
283+
[
284+
"routes/app._pathless._index.tsx",
285+
{
286+
id: "routes/app._pathless._index",
287+
parentId: "routes/app._pathless",
288+
index: true,
289+
path: undefined,
290+
},
291+
],
292+
[
293+
"routes/app._pathless.child.tsx",
294+
{
295+
id: "routes/app._pathless.child",
296+
parentId: "routes/app._pathless",
297+
path: "child",
298+
},
299+
],
275300
[
276301
"routes/folder/route.tsx",
277302
{

packages/remix-dev/config/flat-routes.ts

+1-2
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,7 @@ export function flatRoutesUniversal(
215215
}
216216

217217
if (!config.parentId) config.parentId = "root";
218+
config.path = pathname || undefined;
218219

219220
/**
220221
* We do not try to detect path collisions for pathless layout route
@@ -264,8 +265,6 @@ export function flatRoutesUniversal(
264265

265266
let conflictRouteId = originalPathname + (config.index ? "?index" : "");
266267
let conflict = uniqueRoutes.get(conflictRouteId);
267-
268-
config.path = pathname || undefined;
269268
uniqueRoutes.set(conflictRouteId, config);
270269

271270
if (conflict && (originalPathname || config.index)) {

packages/remix-react/components.tsx

+41-9
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,7 @@ export function RemixRouteError({ id }: { id: string }) {
201201
* - "render": Fetched when the link is rendered
202202
* - "none": Never fetched
203203
*/
204-
type PrefetchBehavior = "intent" | "render" | "none";
204+
type PrefetchBehavior = "intent" | "render" | "none" | "viewport";
205205

206206
export interface RemixLinkProps extends LinkProps {
207207
prefetch?: PrefetchBehavior;
@@ -219,19 +219,35 @@ interface PrefetchHandlers {
219219
onTouchStart?: TouchEventHandler;
220220
}
221221

222-
function usePrefetchBehavior(
222+
function usePrefetchBehavior<T extends HTMLAnchorElement>(
223223
prefetch: PrefetchBehavior,
224224
theirElementProps: PrefetchHandlers
225-
): [boolean, Required<PrefetchHandlers>] {
225+
): [boolean, React.RefObject<T>, Required<PrefetchHandlers>] {
226226
let [maybePrefetch, setMaybePrefetch] = React.useState(false);
227227
let [shouldPrefetch, setShouldPrefetch] = React.useState(false);
228228
let { onFocus, onBlur, onMouseEnter, onMouseLeave, onTouchStart } =
229229
theirElementProps;
230230

231+
let ref = React.useRef<T>(null);
232+
231233
React.useEffect(() => {
232234
if (prefetch === "render") {
233235
setShouldPrefetch(true);
234236
}
237+
238+
if (prefetch === "viewport") {
239+
let callback: IntersectionObserverCallback = (entries) => {
240+
entries.forEach((entry) => {
241+
setShouldPrefetch(entry.isIntersecting);
242+
});
243+
};
244+
let observer = new IntersectionObserver(callback, { threshold: 0.5 });
245+
if (ref.current) observer.observe(ref.current);
246+
247+
return () => {
248+
observer.disconnect();
249+
};
250+
}
235251
}, [prefetch]);
236252

237253
let setIntent = () => {
@@ -260,6 +276,7 @@ function usePrefetchBehavior(
260276

261277
return [
262278
shouldPrefetch,
279+
ref,
263280
{
264281
onFocus: composeEventHandlers(onFocus, setIntent),
265282
onBlur: composeEventHandlers(onBlur, cancelIntent),
@@ -282,17 +299,18 @@ let NavLink = React.forwardRef<HTMLAnchorElement, RemixNavLinkProps>(
282299
let isAbsolute = typeof to === "string" && ABSOLUTE_URL_REGEX.test(to);
283300

284301
let href = useHref(to);
285-
let [shouldPrefetch, prefetchHandlers] = usePrefetchBehavior(
302+
let [shouldPrefetch, ref, prefetchHandlers] = usePrefetchBehavior(
286303
prefetch,
287304
props
288305
);
306+
289307
return (
290308
<>
291309
<RouterNavLink
292-
ref={forwardedRef}
293-
to={to}
294310
{...props}
295311
{...prefetchHandlers}
312+
ref={mergeRefs(forwardedRef, ref)}
313+
to={to}
296314
/>
297315
{shouldPrefetch && !isAbsolute ? (
298316
<PrefetchPageLinks page={href} />
@@ -315,18 +333,18 @@ let Link = React.forwardRef<HTMLAnchorElement, RemixLinkProps>(
315333
let isAbsolute = typeof to === "string" && ABSOLUTE_URL_REGEX.test(to);
316334

317335
let href = useHref(to);
318-
let [shouldPrefetch, prefetchHandlers] = usePrefetchBehavior(
336+
let [shouldPrefetch, ref, prefetchHandlers] = usePrefetchBehavior(
319337
prefetch,
320338
props
321339
);
322340

323341
return (
324342
<>
325343
<RouterLink
326-
ref={forwardedRef}
327-
to={to}
328344
{...props}
329345
{...prefetchHandlers}
346+
ref={mergeRefs(forwardedRef, ref)}
347+
to={to}
330348
/>
331349
{shouldPrefetch && !isAbsolute ? (
332350
<PrefetchPageLinks page={href} />
@@ -1820,3 +1838,17 @@ export const LiveReload =
18201838
/>
18211839
);
18221840
};
1841+
1842+
function mergeRefs<T = any>(
1843+
...refs: Array<React.MutableRefObject<T> | React.LegacyRef<T>>
1844+
): React.RefCallback<T> {
1845+
return (value) => {
1846+
refs.forEach((ref) => {
1847+
if (typeof ref === "function") {
1848+
ref(value);
1849+
} else if (ref != null) {
1850+
(ref as React.MutableRefObject<T | null>).current = value;
1851+
}
1852+
});
1853+
};
1854+
}

0 commit comments

Comments
 (0)