optimized record upsert panel loading to minimize layout jumps

This commit is contained in:
Gani Georgiev
2026-04-22 23:02:56 +03:00
parent 2ddf161314
commit a6002c4622
10 changed files with 104 additions and 80 deletions

View File

@@ -2,6 +2,8 @@
- Added backups list scroll container ([#7655](https://github.com/pocketbase/pocketbase/issues/7655)). - Added backups list scroll container ([#7655](https://github.com/pocketbase/pocketbase/issues/7655)).
- Optimized record upsert panel loading to minimize layout jumps.
## v0.37.3 ## v0.37.3

View File

@@ -11,4 +11,4 @@ PB_DOCS_URL = "https://pocketbase.io/docs"
PB_JS_SDK_URL = "https://github.com/pocketbase/js-sdk" PB_JS_SDK_URL = "https://github.com/pocketbase/js-sdk"
PB_DART_SDK_URL = "https://github.com/pocketbase/dart-sdk" PB_DART_SDK_URL = "https://github.com/pocketbase/dart-sdk"
PB_RELEASES = "https://github.com/pocketbase/pocketbase/releases" PB_RELEASES = "https://github.com/pocketbase/pocketbase/releases"
PB_VERSION = "v0.37.3" PB_VERSION = "v0.37.4-dev"

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

4
ui/dist/index.html vendored
View File

@@ -13,9 +13,9 @@
<!-- prism --> <!-- prism -->
<script src="./libs/prism/prism.js" data-manual></script> <script src="./libs/prism/prism.js" data-manual></script>
<script type="module" crossorigin src="./assets/index-gesvZeL3.js"></script> <script type="module" crossorigin src="./assets/index-DzC-D-6z.js"></script>
<link rel="modulepreload" crossorigin href="./assets/pocketbase.es-B_4DUNUU.js"> <link rel="modulepreload" crossorigin href="./assets/pocketbase.es-B_4DUNUU.js">
<link rel="stylesheet" crossorigin href="./assets/index-DNEbN4Hj.css"> <link rel="stylesheet" crossorigin href="./assets/index-h3OAAQQg.css">
</head> </head>
<body> <body>
</body> </body>

View File

@@ -196,12 +196,6 @@
} }
} }
.record-field-input {
.record-summary {
animation: fadeIn var(--animationSpeed);
}
}
.field-type-bool { .field-type-bool {
&.record-field-view .label { &.record-field-view .label {
min-width: 48px; min-width: 48px;

View File

@@ -36,34 +36,51 @@ export function input(props) {
return; return;
} }
try { const resultRecords = [];
const fieldCollection = app.store.collections.find((c) => c.id == props.field.collectionId); const idsToLoad = [];
// eagerly expand first level presentable relations (if any and the collections are loaded) // check for preloaded expand
const relExpands = []; const expanded = app.utils.toArray(props.record.expand?.[props.field.name]);
const presentableRelationFields = fieldCollection?.fields?.filter( for (const id of ids) {
(f) => !f.hidden && f.presentable && f.type == "relation", const found = expanded.find((r) => r.id == id);
) || []; if (found) {
for (const field of presentableRelationFields) { resultRecords.push(found);
relExpands.push(field.name); } else {
idsToLoad.push(id);
} }
}
const records = await app.pb.collection(props.field.collectionId).getFullList({ try {
requestKey: null, if (idsToLoad.length) {
filter: ids.map((id) => app.pb.filter("id={:id}", { id })).join("||"), const fieldCollection = app.store.collections.find((c) => c.id == props.field.collectionId);
expand: relExpands.join(",") || undefined,
});
// preserve the original order // eagerly expand first level presentable relations (if any and the collections are loaded)
const orderedRecords = []; const relExpands = [];
for (let id of ids) { const presentableRelationFields = fieldCollection?.fields?.filter(
const record = records.find((r) => r.id == id); (f) => !f.hidden && f.presentable && f.type == "relation",
if (record) { ) || [];
orderedRecords.push(record); for (const field of presentableRelationFields) {
relExpands.push(field.name);
}
const records = await app.pb.collection(props.field.collectionId).getFullList({
requestKey: null,
filter: idsToLoad.map((id) => app.pb.filter("id={:id}", { id })).join("||"),
expand: relExpands.join(",") || undefined,
});
// preserve the original order
for (const id of idsToLoad) {
const found = records.find((r) => r.id == id);
if (found) {
resultRecords.push(found);
} else {
console.warn("missing relation id:", id);
}
} }
} }
local.selected = orderedRecords; local.selected = resultRecords;
local.isLoading = false; local.isLoading = false;
} catch (err) { } catch (err) {
if (!err.isAbort) { if (!err.isAbort) {

View File

@@ -193,36 +193,67 @@ function recordUpsertModal(collection, rawRecord, modalSettings) {
data.record = draftClone; data.record = draftClone;
} }
let draftWatcher;
function initDraftWatcher() {
data.initialDraft = getDraft();
draftWatcher?.unwatch();
draftWatcher = watch(() => data.recordHash, (newVal, oldVal) => {
if (typeof oldVal == "undefined") {
return;
}
if (data.hasChanges) {
saveDraft(data.recordHash);
} else {
deleteDraft();
}
});
}
async function initRecord(rawRecord) { async function initRecord(rawRecord) {
data.isLoading = true; data.isLoading = true;
const recordId = typeof rawRecord == "string" ? rawRecord : rawRecord?.id; draftWatcher?.unwatch();
// normalize rawRecord (could be plain id string)
rawRecord = app.utils.isObject(rawRecord) ? rawRecord : { id: rawRecord || "" };
// new record // new record
if (!recordId) { if (!rawRecord.id) {
const record = app.utils.isObject(rawRecord) ? JSON.parse(JSON.stringify(rawRecord)) : {}; data.originalRecord = JSON.parse(JSON.stringify(rawRecord));
data.originalRecord = app.utils.emptyClone(record, ["collectionId", "collectionName"]); data.record = JSON.parse(JSON.stringify(rawRecord));
data.initialDraft = getDraft();
data.record = record;
data.isLoading = false; data.isLoading = false;
data.isLocked = false; data.isLocked = false;
initDraftWatcher();
return; return;
} }
data.isLocked = !!app.store.settings?.meta?.hideControls;
try { try {
// eagerly load to allow elements to show their "update" UI and minimize flickering data.isLocked = !!app.store.settings?.meta?.hideControls;
data.originalRecord = { id: recordId };
const record = await app.pb.collection(collection.name).getOne(recordId, { // preload to minimize content jumps
requestKey: "upsert_load_" + recordId, data.originalRecord = JSON.parse(JSON.stringify(rawRecord));
data.record = JSON.parse(JSON.stringify(rawRecord));
// fetch to ensure that the main record fields are up-to-date
let record = await app.pb.collection(collection.name).getOne(rawRecord.id, {
requestKey: "upsert_load_" + rawRecord.id,
}); });
data.originalRecord = record; // preload existing expands (if any)
data.initialDraft = getDraft(); if (rawRecord.expand) {
data.record = JSON.parse(JSON.stringify(record)); record.expand = JSON.parse(JSON.stringify(rawRecord.expand));
}
// extend, not overwrite, to prevent reseting the reference passed down to the inputs
Object.assign(data.originalRecord, JSON.parse(JSON.stringify(record)));
Object.assign(data.record, JSON.parse(JSON.stringify(record)));
data.isLoading = false; data.isLoading = false;
initDraftWatcher();
} catch (err) { } catch (err) {
if (!err?.isAbort) { if (!err?.isAbort) {
app.checkApiError(err); app.checkApiError(err);
@@ -297,12 +328,12 @@ function recordUpsertModal(collection, rawRecord, modalSettings) {
data.originalRecord = structuredClone(record); data.originalRecord = structuredClone(record);
data.record = structuredClone(record); data.record = structuredClone(record);
} else { } else {
// don't overwrite to prevent loosing the reference passed down to the inputs // extend, not overwrite, to prevent reseting the reference passed down to the inputs
Object.assign(data.originalRecord, structuredClone(record)); Object.assign(data.originalRecord, structuredClone(record));
Object.assign(data.record, structuredClone(record)); Object.assign(data.record, structuredClone(record));
} }
modalSettings.onsave?.(structuredClone(record), isNew); modalSettings.onsave?.(record, isNew);
// reset all errors // reset all errors
app.store.errors = null; app.store.errors = null;
@@ -360,8 +391,6 @@ function recordUpsertModal(collection, rawRecord, modalSettings) {
initRecord(clone); initRecord(clone);
} }
const watchers = [];
function mainTab() { function mainTab() {
return [ return [
t.div( t.div(
@@ -605,31 +634,12 @@ function recordUpsertModal(collection, rawRecord, modalSettings) {
onbeforeopen: () => { onbeforeopen: () => {
initRecord(rawRecord); initRecord(rawRecord);
watchers.push(
watch(() => data.recordHash, (newHash, oldHash) => {
if (!oldHash || !newHash || newHash == "{}" || oldHash == "{}") {
return;
}
saveDraft(newHash);
}),
);
return modalSettings.onbeforeopen?.(el); return modalSettings.onbeforeopen?.(el);
}, },
onafteropen: (el) => { onafteropen: (el) => {
modalSettings.onafteropen?.(el); modalSettings.onafteropen?.(el);
}, },
onbeforeclose: (el, forceClosed) => { onbeforeclose: (el, forceClosed) => {
if (
// there are no unsaved changes
!data.hasChanges
// the form has been edited
&& data.initialDraftHash != getDraftHash()
) {
deleteDraft();
}
if (forceClosed) { if (forceClosed) {
return modalSettings.onbeforeclose?.(el); return modalSettings.onbeforeclose?.(el);
} }
@@ -656,11 +666,10 @@ function recordUpsertModal(collection, rawRecord, modalSettings) {
}, },
onafterclose: (el) => { onafterclose: (el) => {
modalSettings.onafterclose?.(el); modalSettings.onafterclose?.(el);
watchers.forEach((w) => w?.unwatch());
el?.remove(); el?.remove();
}, },
onunmount: () => { onunmount: () => {
watchers.forEach((w) => w?.unwatch()); draftWatcher?.unwatch();
}, },
}, },
t.header( t.header(

View File

@@ -94,9 +94,9 @@ window.app.components.recordsList = function(propsArg = {}) {
// eagerly expand first level relations // eagerly expand first level relations
// (to prevent too many relation queries) // (to prevent too many relation queries)
const relExpands = []; const relExpands = [];
const relationFields = props.collection.fields.filter( const relationFields = props.collection.fields?.filter(
(f) => !f.hidden && f.type == "relation", (f) => !f.hidden && f.type == "relation",
); ) || [];
for (const field of relationFields) { for (const field of relationFields) {
relExpands.push(field.name); relExpands.push(field.name);
} }

View File

@@ -348,6 +348,8 @@ const utils = {
clone[prop] = ""; clone[prop] = "";
} else if (typeof clone[prop] == "number") { } else if (typeof clone[prop] == "number") {
clone[prop] = 0; clone[prop] = 0;
} else if (typeof clone[prop] == "boolean") {
clone[prop] = false;
} else if (Array.isArray(clone[prop])) { } else if (Array.isArray(clone[prop])) {
clone[prop] = []; clone[prop] = [];
} else if (app.utils.isObject(clone[prop])) { } else if (app.utils.isObject(clone[prop])) {