Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add <CheckForApplicationUpdate> to suggest a reload when the application code has changed #9059

Merged
merged 21 commits into from
Aug 10, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 146 additions & 0 deletions docs/CheckForApplicationUpdate.md
slax57 marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
---
layout: default
title: "The CheckForApplicationUpdate component"
---

# `CheckForApplicationUpdate`

When your admin application is a Single Page Application, users who keep a browser tab open at all times might not use the most recent version of the application unless you tell them to refresh the page.

This component regularly checks whether the application source code has changed and prompts users to reload the page when an update is available. To detect updates, it fetches the current URL at regular intervals and compares the hash of the response content (usually the HTML source). This should be enough in most cases as bundlers usually update the links to the application bundles after an update.

![CheckForApplicationUpdate](./img/CheckForApplicationUpdate.png)

## Usage

Include this component in a custom layout:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should do it by default in create-react-admin.


```tsx
// in src/MyLayout.tsx
import { CheckForApplicationUpdate, Layout, LayoutProps } from 'react-admin';

export const MyLayout = ({ children, ...props }: LayoutProps) => (
<Layout {...props}>
{children}
<CheckForApplicationUpdate />
</Layout>
);

// in src/App.tsx
import { Admin, ListGuesser, Resource } from 'react-admin';
import { MyLayout } from './MyLayout';

export const App = () => (
<Admin layout={MyLayout}>
<Resource name="posts" list={ListGuesser} />
</Admin>
);
```

## Props

`<CheckForApplicationUpdate>` accepts the following props:

| Prop | Required | Type | Default | Description |
| --------------- | -------- | -------- | ------------------ |-------------------------------------------------------------------- |
| `interval` | Optional | number | `3600000` (1 hour) | The interval in milliseconds between two checks |
| `disabled` | Optional | boolean | `false` in `production` mode | Whether the automatic check is disabled |
| `notification` | Optional | ReactElement | | The notification to display to the user when an update is available |
| `url` | Optional | string | current URL | The URL to download to check for code update |

## `interval`

You can customize the interval between each check by providing the `interval` prop. It accepts a number of milliseconds and is set to `3600000` (1 hour) by default.

```tsx
// in src/MyLayout.tsx
import { CheckForApplicationUpdate, Layout, LayoutProps } from 'react-admin';

const HALF_HOUR = 30 * 60 * 1000;

export const MyLayout = ({ children, ...props }: LayoutProps) => (
<Layout {...props}>
{children}
<CheckForApplicationUpdate interval={HALF_HOUR} />
</Layout>
);
```

## `disabled`

You can dynamically disable the automatic application update detection by providing the `disabled` prop. By default, it's only enabled in `production` mode.

```tsx
// in src/MyLayout.tsx
import { CheckForApplicationUpdate, Layout, LayoutProps } from 'react-admin';

export const MyLayout = ({ children, ...props }: LayoutProps) => (
<Layout {...props}>
{children}
<CheckForApplicationUpdate disabled={process.env.NODE_ENV !== 'production'} />
fzaninotto marked this conversation as resolved.
Show resolved Hide resolved
</Layout>
);
```

## `notification`

You can customize the notification shown to users when an update is available by passing your own element to the `notification` prop.
Note that you must wrap your component with `forwardRef`.

```tsx
// in src/MyLayout.tsx
import { forwardRef } from 'react';
import { Layout, CheckForApplicationUpdate } from 'react-admin';

const CustomAppUpdatedNotification = forwardRef((props, ref) => (
<Alert
ref={ref}
severity="info"
action={
<Button
color="inherit"
size="small"
onClick={() => window.location.reload()}
>
Update
</Button>
}
>
A new version of the application is available. Please update.
</Alert>
));

const MyLayout = ({ children, ...props }) => (
<Layout {...props}>
{children}
<CheckForApplicationUpdate notification={<CustomAppUpdatedNotification />}/>
</Layout>
);
```

If you just want to customize the notification texts, including the button, check out the [Internationalization section](#internationalization).

## `url`

You can customize the URL fetched to detect updates by providing the `url` prop. By default it's the current URL.

```tsx
// in src/MyLayout.tsx
import { CheckForApplicationUpdate, Layout, LayoutProps } from 'react-admin';

const MY_APP_ROOT_URL = 'http://admin.mycompany.com';

export const MyLayout = ({ children, ...props }: LayoutProps) => (
<Layout {...props}>
{children}
<CheckForApplicationUpdate url={MY_APP_ROOT_URL} />
</Layout>
);
```

## Internationalization

You can customize the texts of the default notification by overriding the following keys:

* `ra.notification.application_update_available`: the notification text
* `ra.action.update_application`: the reload button text
40 changes: 40 additions & 0 deletions docs/CreateReactApp.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,43 @@ Now, start the server with `yarn start`, browse to `http://localhost:3000/`, and
![Working Page](./img/nextjs-react-admin.webp)

Your app is now up and running, you can start tweaking it.

## Ensuring Users Have The Latest Version

If your users might keep the application open for a long time, it's a good idea to add the [`<CheckForApplicationUpdate>`](./CheckForApplicationUpdate.md) component. It will check whether a more recent version of your application is available and prompt users to reload their browser tab.

To determine whether your application has been updated, it fetches the current page at a regular interval, builds an hash of the response content (usually the HTML) and compares it with the previous hash.

To enable it, start by creating a custom layout:

```tsx
// in src/admin/MyLayout.tsx
import { CheckForApplicationUpdate, Layout, LayoutProps } from 'react-admin';

export const MyLayout = ({ children, ...props }: LayoutProps) => (
<Layout {...props}>
{children}
<CheckForApplicationUpdate />
</Layout>
);
```

Then use this layout in your app:

```diff
import { Admin, Resource, ListGuesser } from "react-admin";
import jsonServerProvider from "ra-data-json-server";
+import { MyLayout } from './MyLayout';

const dataProvider = jsonServerProvider("https://jsonplaceholder.typicode.com");

const App = () => (
- <Admin dataProvider={dataProvider}>
+ <Admin dataProvider={dataProvider} layout={MyLayout}>
<Resource name="posts" list={ListGuesser} />
<Resource name="comments" list={ListGuesser} />
</Admin>
);

export default App;
```
1 change: 1 addition & 0 deletions docs/Reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ title: "Index"
**- C -**
* [`<Calendar>`](./Calendar.md)<img class="icon" src="./img/premium.svg" />
* [`<CheckboxGroupInput>`](./CheckboxGroupInput.md)
* [`<CheckForApplicationUpdate>`](./CheckForApplicationUpdate.md)
* [`<ChipField>`](./ChipField.md)
* [`<CloneButton>`](./CloneButton.md)
* [`<CompleteCalendar>`](https://marmelab.com/ra-enterprise/modules/ra-calendar#completecalendar)<img class="icon" src="./img/premium.svg" />
Expand Down
40 changes: 40 additions & 0 deletions docs/Vite.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,46 @@ Now, start the server with `yarn dev`, browse to `http://localhost:5173/`, and y

Your app is now up and running, you can start tweaking it.

## Ensuring Users Have The Latest Version

If your users might keep the application open for a long time, it's a good idea to add the [`<CheckForApplicationUpdate>`](./CheckForApplicationUpdate.md) component. It will check whether a more recent version of your application is available and prompt users to reload their browser tab.

To determine whether your application has been updated, it fetches the current page at a regular interval, builds an hash of the response content (usually the HTML) and compares it with the previous hash.

To enable it, start by creating a custom layout:

```tsx
// in src/admin/MyLayout.tsx
import { CheckForApplicationUpdate, Layout, LayoutProps } from 'react-admin';

export const MyLayout = ({ children, ...props }: LayoutProps) => (
<Layout {...props}>
{children}
<CheckForApplicationUpdate />
</Layout>
);
```

Then use this layout in your app:

```diff
import { Admin, Resource, ListGuesser } from "react-admin";
import jsonServerProvider from "ra-data-json-server";
+import { MyLayout } from './MyLayout';

const dataProvider = jsonServerProvider("https://jsonplaceholder.typicode.com");

const App = () => (
- <Admin dataProvider={dataProvider}>
+ <Admin dataProvider={dataProvider} layout={MyLayout}>
<Resource name="posts" list={ListGuesser} />
<Resource name="comments" list={ListGuesser} />
</Admin>
);

export default App;
```

## Troubleshooting

### Error about `global` Being `undefined`
Expand Down
Binary file added docs/img/CheckForApplicationUpdate.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions docs/navigation.html
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,7 @@
<li {% if page.path == 'Search.md' %} class="active" {% endif %}><a class="nav-link" href="./Search.html"><code>&lt;Search&gt;</code><img class="premium" src="./img/premium.svg" /></a></li>
<li {% if page.path == 'Confirm.md' %} class="active" {% endif %}><a class="nav-link" href="./Confirm.html"><code>&lt;Confirm&gt;</code></a></li>
<li {% if page.path == 'Buttons.md' %} class="active" {% endif %}><a class="nav-link" href="./Buttons.html">Buttons</a></li>
<li {% if page.path == 'CheckForApplicationUpdate.md' %} class="active" {% endif %}><a class="nav-link" href="./CheckForApplicationUpdate.html"><code>&lt;CheckForApplicationUpdate&gt;</code></a></li>
<li {% if page.path == 'UpdateButton.md' %} class="active" {% endif %}><a class="nav-link" href="./UpdateButton.html"><code>&lt;UpdateButton&gt;</code></a></li>
<li {% if page.path == 'RecordRepresentation.md' %} class="active" {% endif %}><a class="nav-link" href="./RecordRepresentation.html"><code>&lt;RecordRepresentation&gt;</code></a></li>
<li {% if page.path == 'useGetRecordRepresentation.md' %} class="active" {% endif %}><a class="nav-link" href="./useGetRecordRepresentation.html"><code>useGetRecordRepresentation</code></a></li>
Expand Down
3 changes: 2 additions & 1 deletion examples/crm/src/Layout.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React, { HtmlHTMLAttributes } from 'react';
import { CssBaseline, Container } from '@mui/material';
import { CoreLayoutProps } from 'react-admin';
import { CoreLayoutProps, CheckForApplicationUpdate } from 'react-admin';
import { ErrorBoundary } from 'react-error-boundary';

import { Error } from 'react-admin';
Expand All @@ -18,6 +18,7 @@ const Layout = ({ children }: LayoutProps) => (
</ErrorBoundary>
</main>
</Container>
<CheckForApplicationUpdate interval={30 * 1000} />
</>
);

Expand Down
1 change: 1 addition & 0 deletions packages/ra-core/src/util/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,4 @@ export * from './shallowEqual';
export * from './LabelPrefixContext';
export * from './LabelPrefixContextProvider';
export * from './useLabelPrefix';
export * from './useCheckForApplicationUpdate';
94 changes: 94 additions & 0 deletions packages/ra-core/src/util/useCheckForApplicationUpdate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { useEffect, useRef } from 'react';
import { useEvent } from './useEvent';

/**
* Checks if the application code has changed and calls the provided onNewVersionAvailable function when needed.
*
* It checks for code update by downloading the provided URL (default to the HTML page) and
* comparing the hash of the response with the hash of the current page.
*
* @param {UseCheckForApplicationUpdateOptions} options The options
* @param {Function} options.onNewVersionAvailable The function to call when a new version of the application is available.
* @param {string} options.url Optional. The URL to download to check for code update. Defaults to the current URL.
* @param {number} options.interval Optional. The interval in milliseconds between two checks. Defaults to 3600000 (1 hour).
* @param {boolean} options.disabled Optional. Whether the check should be disabled. Defaults to false.
*/
export const useCheckForApplicationUpdate = (
options: UseCheckForApplicationUpdateOptions
) => {
const {
url = window.location.href,
interval: delay = ONE_HOUR,
onNewVersionAvailable: onNewVersionAvailableProp,
disabled = process.env.NODE_ENV !== 'production',
} = options;
const currentHash = useRef<number>();
const onNewVersionAvailable = useEvent(onNewVersionAvailableProp);

useEffect(() => {
if (disabled) return;

getHashForUrl(url).then(hash => {
if (hash != null) {
currentHash.current = hash;
}
});
}, [disabled, url]);

useEffect(() => {
if (disabled) return;

const interval = setInterval(() => {
getHashForUrl(url)
.then(hash => {
if (hash != null && currentHash.current !== hash) {
// Store the latest hash to avoid calling the onNewVersionAvailable function multiple times
// or when users have closed the notification
currentHash.current = hash;
onNewVersionAvailable();
}
})
.catch(() => {
// Ignore errors to avoid issues when connectivity is lost
});
}, delay);
return () => clearInterval(interval);
slax57 marked this conversation as resolved.
Show resolved Hide resolved
}, [delay, onNewVersionAvailable, disabled, url]);
};

const getHashForUrl = async (url: string) => {
try {
const response = await fetch(url);
if (!response.ok) return null;
const text = await response.text();
return hash(text);
} catch (e) {
return null;
}
};

// Simple hash function, taken from https://stackoverflow.com/a/52171480/3723993, suggested by Copilot
const hash = (value: string, seed = 0) => {
let h1 = 0xdeadbeef ^ seed,
h2 = 0x41c6ce57 ^ seed;
for (let i = 0, ch; i < value.length; i++) {
ch = value.charCodeAt(i);
h1 = Math.imul(h1 ^ ch, 2654435761);
h2 = Math.imul(h2 ^ ch, 1597334677);
}
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507);
h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909);
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507);
h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909);

return 4294967296 * (2097151 & h2) + (h1 >>> 0);
};

const ONE_HOUR = 1000 * 60 * 60;

export interface UseCheckForApplicationUpdateOptions {
onNewVersionAvailable: () => void;
interval?: number;
url?: string;
disabled?: boolean;
}
2 changes: 2 additions & 0 deletions packages/ra-language-english/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ const englishMessages: TranslationMessages = {
open: 'Open',
toggle_theme: 'Toggle Theme',
select_columns: 'Columns',
update_application: 'Reload Application',
},
boolean: {
true: 'Yes',
Expand Down Expand Up @@ -158,6 +159,7 @@ const englishMessages: TranslationMessages = {
canceled: 'Action cancelled',
logged_out: 'Your session has ended, please reconnect.',
not_authorized: "You're not authorized to access this resource.",
application_update_available: 'A new version is available.',
},
validation: {
required: 'Required',
Expand Down
Loading
Loading