Skip to content

Commit 8704000

Browse files
authored
feat: improve local hmr experience (#581)
1 parent 26a1231 commit 8704000

4 files changed

Lines changed: 121 additions & 3 deletions

File tree

.changeset/heavy-zebras-breathe.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@blinkk/root-cms': patch
3+
'@blinkk/root': patch
4+
---
5+
6+
feat: improve local hmr experience

packages/root-cms/ui/components/DocEditor/DocEditor.tsx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import {
4343
UseDraftHook,
4444
} from '../../hooks/useDraft.js';
4545
import {joinClassNames} from '../../utils/classes.js';
46+
import {preserveUiState} from '../../utils/doc-ui-state.js';
4647
import {
4748
CMSDoc,
4849
testIsScheduled,
@@ -53,6 +54,7 @@ import {getDefaultFieldValue} from '../../utils/fields.js';
5354
import {flattenNestedKeys} from '../../utils/objects.js';
5455
import {autokey} from '../../utils/rand.js';
5556
import {getPlaceholderKeys, strFormat} from '../../utils/str-format.js';
57+
import {testFieldEmpty} from '../../utils/test-field-empty.js';
5658
import {formatDateTime} from '../../utils/time.js';
5759
import {
5860
useVirtualClipboard,
@@ -67,20 +69,20 @@ import {useEditJsonModal} from '../EditJsonModal/EditJsonModal.js';
6769
import {useEditTranslationsModal} from '../EditTranslationsModal/EditTranslationsModal.js';
6870
import {useLocalizationModal} from '../LocalizationModal/LocalizationModal.js';
6971
import {usePublishDocModal} from '../PublishDocModal/PublishDocModal.js';
70-
import './DocEditor.css';
7172
import {Viewers} from '../Viewers/Viewers.js';
7273
import {BooleanField} from './fields/BooleanField.js';
7374
import {DateTimeField} from './fields/DateTimeField.js';
7475
import {FieldProps} from './fields/FieldProps.js';
7576
import {FileField} from './fields/FileField.js';
7677
import {ImageField} from './fields/ImageField.js';
7778
import {MultiSelectField} from './fields/MultiSelectField.js';
79+
import {NumberField} from './fields/NumberField.js';
7880
import {ReferenceField} from './fields/ReferenceField.js';
7981
import {RichTextField} from './fields/RichTextField.js';
8082
import {SelectField} from './fields/SelectField.js';
8183
import {StringField} from './fields/StringField.js';
82-
import {NumberField} from './fields/NumberField.js';
83-
import {testFieldEmpty} from '../../utils/test-field-empty.js';
84+
85+
import './DocEditor.css';
8486

8587
interface DocEditorProps {
8688
docId: string;
@@ -143,6 +145,10 @@ export function DocEditor(props: DocEditorProps) {
143145
if (deeplink) {
144146
setDeeplink(deeplink);
145147
}
148+
const callback = preserveUiState();
149+
return () => {
150+
callback();
151+
};
146152
}, [loading]);
147153

148154
return (
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
const SCROLL_KEY = 'root::cms::scroll';
2+
const OPEN_KEY = 'root::cms::open';
3+
4+
/*
5+
* Save the scroll position and open field editors prior to reloading the page.
6+
*/
7+
function saveUiState() {
8+
const side = document.querySelector(
9+
'.DocumentPage__side'
10+
) as HTMLElement | null;
11+
if (side) {
12+
sessionStorage.setItem(SCROLL_KEY, String(side.scrollTop));
13+
}
14+
const openSummaries = Array.from(
15+
document.querySelectorAll(
16+
'.DocEditor__ObjectFieldDrawer__drawer[open], .DocEditor__ArrayField__item[open] summary'
17+
)
18+
)
19+
.map((el) => el.id)
20+
.filter(Boolean);
21+
if (openSummaries.length > 0) {
22+
sessionStorage.setItem(OPEN_KEY, JSON.stringify(openSummaries));
23+
}
24+
}
25+
26+
/*
27+
* Restore the scroll position and open field editors when page is loaded.
28+
*/
29+
function restoreUiState() {
30+
// Restore open editors.
31+
const open = sessionStorage.getItem(OPEN_KEY);
32+
if (open) {
33+
try {
34+
const ids = JSON.parse(open) as string[];
35+
ids.forEach((id) => {
36+
const summary = document.getElementById(id);
37+
if (summary) {
38+
const details = summary.closest('details');
39+
if (details) {
40+
details.open = true;
41+
}
42+
}
43+
});
44+
} catch {
45+
/* ignore */
46+
}
47+
}
48+
// Restore scroll position.
49+
const side = document.querySelector(
50+
'.DocumentPage__side'
51+
) as HTMLElement | null;
52+
const scroll = sessionStorage.getItem(SCROLL_KEY);
53+
if (side && scroll) {
54+
side.scrollTop = parseInt(scroll, 10);
55+
}
56+
}
57+
58+
/** Returns whether the UI state should be saved and restored. */
59+
function testPreserveUiState() {
60+
// Currently, this is mainly used for development. Specifically, when the page is HMR'ed, we
61+
// want to preserve the scroll position and open editors.
62+
return window.location.hostname === 'localhost';
63+
}
64+
65+
/** Preserves the UI state across page reloads. */
66+
export function preserveUiState(): () => void {
67+
// Do nothing if the UI state should not be preserved.
68+
if (!testPreserveUiState()) {
69+
return () => {};
70+
}
71+
// Restore the UI state once the data is loaded.
72+
restoreUiState();
73+
const beforeUnloadHandler = () => {
74+
// Save the scroll position and open field editors prior to reloading the page.
75+
saveUiState();
76+
};
77+
// Before the page is unloaded, save the ui state.
78+
window.addEventListener('beforeunload', beforeUnloadHandler);
79+
// Return a callback that removes the event listener.
80+
return () => {
81+
window.removeEventListener('beforeunload', beforeUnloadHandler);
82+
};
83+
}

packages/root/src/node/vite.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import fs from 'node:fs';
2+
import path from 'node:path';
13
import {createServer, ViteDevServer} from 'vite';
24
import {RootConfig} from '../core/config.js';
35
import {getVitePlugins} from '../core/plugin.js';
@@ -21,6 +23,23 @@ export async function createViteServer(
2123
const rootDir = rootConfig.rootDir;
2224
const viteConfig = rootConfig.vite || {};
2325

26+
/** Ignore paths from .gitignore when hot reloading. */
27+
const gitignorePath = path.join(rootDir, '.gitignore');
28+
let ignored: Array<string | RegExp> = ['**/dist/**'];
29+
if (fs.existsSync(gitignorePath)) {
30+
try {
31+
const contents = fs.readFileSync(gitignorePath, 'utf8');
32+
const patterns = contents
33+
.split(/\r?\n/)
34+
.map((l) => l.trim())
35+
.filter((l) => l && !l.startsWith('#'))
36+
.map((p) => (p.endsWith('/') ? `${p}**` : p));
37+
ignored = [...ignored, ...patterns];
38+
} catch {
39+
/** ignore errors reading gitignore */
40+
}
41+
}
42+
2443
let hmrOptions = viteConfig.server?.hmr;
2544
if (options?.hmr === false) {
2645
hmrOptions = false;
@@ -42,6 +61,10 @@ export async function createViteServer(
4261
...(viteConfig.server || {}),
4362
middlewareMode: true,
4463
hmr: hmrOptions,
64+
watch: {
65+
...(viteConfig.server?.watch || {}),
66+
ignored,
67+
},
4568
},
4669
appType: 'custom',
4770
optimizeDeps: {

0 commit comments

Comments
 (0)