From 1797a5214b4e9e9b87e28774b471317f2674fab7 Mon Sep 17 00:00:00 2001 From: Davide Silvestri <75379892+silvestrid@users.noreply.github.com> Date: Wed, 18 Feb 2026 16:17:33 +0100 Subject: [PATCH 1/8] fix: n+1 issue in send_unread_notifications_by_email_to_users_matching_filters (#4802) --- backend/justfile | 4 +- .../src/baserow/core/notifications/handler.py | 6 +- .../test_notifications_handler.py | 91 +++++++++++++++++++ 3 files changed, 96 insertions(+), 5 deletions(-) diff --git a/backend/justfile b/backend/justfile index c86bbced1d..c3dfce6130 100644 --- a/backend/justfile +++ b/backend/justfile @@ -576,11 +576,11 @@ alias mk := makemigrations # Open Django shell with shell_plus and SQL logging. Pass --print-sql to enable SQL query logging. [group('2 - development')] -shell_plus: _check-dev +shell_plus *args: _check-dev #!/usr/bin/env bash set -euo pipefail {{ _load_env }} - {{ uv_run }} baserow shell_plus + {{ uv_run }} baserow shell_plus {{ args }} alias sp := shell_plus diff --git a/backend/src/baserow/core/notifications/handler.py b/backend/src/baserow/core/notifications/handler.py index 8e6cb76d94..01bc03984d 100644 --- a/backend/src/baserow/core/notifications/handler.py +++ b/backend/src/baserow/core/notifications/handler.py @@ -702,9 +702,9 @@ def filter_and_annotate_users_with_notifications_to_send_by_email( notifications_to_send_by_email_prefetch = Prefetch( "notifications", - queryset=Notification.objects.filter( - id__in=unsent_notification_subquery - ).distinct(), + queryset=Notification.objects.filter(id__in=unsent_notification_subquery) + .distinct() + .select_related("sender"), to_attr="unsent_email_notifications", ) diff --git a/backend/tests/baserow/core/notifications/test_notifications_handler.py b/backend/tests/baserow/core/notifications/test_notifications_handler.py index b807d16fea..5420b9c944 100644 --- a/backend/tests/baserow/core/notifications/test_notifications_handler.py +++ b/backend/tests/baserow/core/notifications/test_notifications_handler.py @@ -1,8 +1,10 @@ from unittest.mock import MagicMock, call, patch from django.conf import settings +from django.db import connection from django.db.models import Q from django.test import override_settings +from django.test.utils import CaptureQueriesContext import pytest @@ -14,6 +16,11 @@ UserNotificationsGrouper, ) from baserow.core.notifications.models import Notification, NotificationRecipient +from baserow.core.notifications.registries import ( + EmailNotificationTypeMixin, + NotificationType, + notification_type_registry, +) from baserow.core.user.handler import UserHandler from .utils import custom_notification_types_registered @@ -813,3 +820,87 @@ def test_email_notifications_are_not_sent_if_already_cleared_by_user( assert res.remaining_users_to_notify_count == 0 mock_get_mail_connection.assert_not_called() + + +@pytest.mark.django_db(transaction=True) +@patch("baserow.core.notifications.handler.get_mail_connection") +def test_email_notifications_queries_are_limited( + mock_get_mail_connection, + data_fixture, + mutable_notification_type_registry, +): + """ + The query count for sending notification emails must not scale with the + number of notifications or the number of users. In particular the + prefetch must include select_related("sender") so that accessing + notification.sender during email rendering doesn't cause one query per + notification. + """ + + mock_connection = MagicMock() + mock_get_mail_connection.return_value = mock_connection + + class SenderAccessingNotification(EmailNotificationTypeMixin, NotificationType): + type = "test_sender_accessing_notification" + + @classmethod + def get_notification_title_for_email(cls, notification, context): + return f"Notification from {notification.sender.first_name}" + + @classmethod + def get_notification_description_for_email(cls, notification, context): + return None + + notification_type_registry.register(SenderAccessingNotification()) + try: + sender = data_fixture.create_user(first_name="Sender") + + # --- Warm-up run to prime internal caches (license cache, etc.) --- + warmup_user = data_fixture.create_user() + data_fixture.create_notification_for_users( + recipients=[warmup_user], + sender=sender, + notification_type=SenderAccessingNotification.type, + ) + NotificationHandler.send_unread_notifications_by_email_to_users_matching_filters( + Q(pk=warmup_user.pk) + ) + mock_get_mail_connection.reset_mock() + mock_connection.reset_mock() + + # --- Run 1: 1 user, 1 notification --- + user_a = data_fixture.create_user() + data_fixture.create_notification_for_users( + recipients=[user_a], + sender=sender, + notification_type=SenderAccessingNotification.type, + ) + + with CaptureQueriesContext(connection) as ctx_small: + NotificationHandler.send_unread_notifications_by_email_to_users_matching_filters( + Q(pk=user_a.pk) + ) + assert len(ctx_small) # sanity + + # --- Run 2: 2 users, 5 notifications each --- + mock_get_mail_connection.reset_mock() + mock_connection.reset_mock() + + user_b = data_fixture.create_user() + user_c = data_fixture.create_user() + for _ in range(5): + data_fixture.create_notification_for_users( + recipients=[user_b, user_c], + sender=sender, + notification_type=SenderAccessingNotification.type, + ) + + with CaptureQueriesContext(connection) as ctx_large: + NotificationHandler.send_unread_notifications_by_email_to_users_matching_filters( + Q(pk__in=[user_b.pk, user_c.pk]) + ) + + # Query count must be identical: no N+1 on sender, users, or notifications. + assert len(ctx_large) == len(ctx_small) + finally: + notification_type_registry.unregister(SenderAccessingNotification.type) From c532ff1ced8a7ad3b56e1eab17520f7e26e83632 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9mie=20Pardou?= <571533+jrmi@users.noreply.github.com> Date: Wed, 18 Feb 2026 16:40:41 +0100 Subject: [PATCH 2/8] Fix FormInput error on blur (#4797) --- web-frontend/modules/core/components/FormInput.vue | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/web-frontend/modules/core/components/FormInput.vue b/web-frontend/modules/core/components/FormInput.vue index 35e62a83d3..d2840c2d25 100644 --- a/web-frontend/modules/core/components/FormInput.vue +++ b/web-frontend/modules/core/components/FormInput.vue @@ -134,6 +134,12 @@ function onInput(e) { } function onBlur(e) { + if (!input.value) { + // The FormInput element was unmounted in the meantime. It happens in the login + // form for instance if you hit enter after filling the form. + return + } + const raw = input.value.value if (!raw && props.defaultValueWhenEmpty !== null) { From 4d9092a241e2d49ea81a350f351a67eac96f3364 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9mie=20Pardou?= <571533+jrmi@users.noreply.github.com> Date: Wed, 18 Feb 2026 17:15:17 +0100 Subject: [PATCH 3/8] Fix context closing error when panning (#4801) --- .../automation/components/workflow/WorkflowNodeContent.vue | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/web-frontend/modules/automation/components/workflow/WorkflowNodeContent.vue b/web-frontend/modules/automation/components/workflow/WorkflowNodeContent.vue index b3d08d7e53..ad90fcb1d6 100644 --- a/web-frontend/modules/automation/components/workflow/WorkflowNodeContent.vue +++ b/web-frontend/modules/automation/components/workflow/WorkflowNodeContent.vue @@ -166,9 +166,10 @@ onMove(() => { const editNodeContext = ref(null) const editNodeContextToggle = ref(null) + const openEditContext = () => { if (editNodeContext.value && editNodeContextToggle.value) { - activeNodeContext.value = editNodeContext + activeNodeContext.value = editNodeContext.value editNodeContext.value.toggle( editNodeContextToggle.value, 'bottom', @@ -179,12 +180,13 @@ const openEditContext = () => { } const replaceNodeContext = ref(null) + const openReplaceContext = async () => { editNodeContext.value.hide() // As the target isn't the element that triggered the show of the context it is not // ignored by the click outside handler and it immediately closes the context await flushPromises() - activeNodeContext.value = replaceNodeContext + activeNodeContext.value = replaceNodeContext.value replaceNodeContext.value.toggle( editNodeContextToggle.value, 'bottom', From 93c8aadcf36915f5ee022847d0a23c57ba652c38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9mie=20Pardou?= <571533+jrmi@users.noreply.github.com> Date: Wed, 18 Feb 2026 17:15:47 +0100 Subject: [PATCH 4/8] Fix blur event triggered on unmounted element (#4805) --- web-frontend/modules/database/mixins/dateField.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/web-frontend/modules/database/mixins/dateField.js b/web-frontend/modules/database/mixins/dateField.js index 8507e162c5..b9e39e2b20 100644 --- a/web-frontend/modules/database/mixins/dateField.js +++ b/web-frontend/modules/database/mixins/dateField.js @@ -208,7 +208,9 @@ export default { * hidden. */ blur(context, event) { - context.hide() + // The ?. prevents an exception when the context was removed from the DOM then + // the blur event is triggered + context?.hide() }, }, } From 4780c3a3c48d2e0299b5d5ea8065ce493197a803 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9mie=20Pardou?= <571533+jrmi@users.noreply.github.com> Date: Wed, 18 Feb 2026 17:32:02 +0100 Subject: [PATCH 5/8] Fix view not found being an issue in sentry (#4806) --- web-frontend/modules/database/pages/form.vue | 6 +++++- web-frontend/modules/database/pages/publicView.vue | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/web-frontend/modules/database/pages/form.vue b/web-frontend/modules/database/pages/form.vue index 5181e6cc2c..05e2151b5b 100644 --- a/web-frontend/modules/database/pages/form.vue +++ b/web-frontend/modules/database/pages/form.vue @@ -184,7 +184,11 @@ const { data, error } = await useAsyncData( ) if (error.value) { - throw error.value + if (error.value.statusCode === 404) { + showError(error.value) + } else { + throw error.value + } } if (data.value?.redirect) { diff --git a/web-frontend/modules/database/pages/publicView.vue b/web-frontend/modules/database/pages/publicView.vue index 4525ffee0d..17681d97c4 100644 --- a/web-frontend/modules/database/pages/publicView.vue +++ b/web-frontend/modules/database/pages/publicView.vue @@ -139,7 +139,11 @@ const { data, error } = await useAsyncData( ) if (error.value) { - throw error.value + if (error.value.statusCode === 404) { + showError(error.value) + } else { + throw error.value + } } if (data.value?.redirect) { From 7a2a8fcdeef000313243b1bd6b6cafe4904063d4 Mon Sep 17 00:00:00 2001 From: Jonathan Adeline Date: Wed, 18 Feb 2026 20:33:15 +0400 Subject: [PATCH 6/8] handle client side locale switch withen fallback (#4799) --- web-frontend/modules/core/plugins/i18n.js | 26 ++++++++++++++--------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/web-frontend/modules/core/plugins/i18n.js b/web-frontend/modules/core/plugins/i18n.js index 296ff97966..01cc142ffc 100644 --- a/web-frontend/modules/core/plugins/i18n.js +++ b/web-frontend/modules/core/plugins/i18n.js @@ -7,17 +7,23 @@ export default defineNuxtPlugin({ moment.locale($i18n.locale.value) - $i18n.onLanguageSwitched = (oldLocale, newLocale) => { - moment.locale(newLocale) - } - - if ($i18n.locale.value !== 'en') { - try { - $i18n.fallbackLocale.value = 'en' - await $i18n.loadLocaleMessages('en') - } catch (error) { - console.warn('Failed to load fallback locale messages:', error) + const loadFallbackIfNeeded = async (locale) => { + if (locale !== 'en') { + try { + $i18n.fallbackLocale.value = 'en' + await $i18n.loadLocaleMessages('en') + } catch (error) { + console.warn('Failed to load fallback locale messages:', error) + } } } + + // Use watch to react to client side locale switch + watch($i18n.locale, async (newLocale, oldLocale) => { + moment.locale(newLocale) + await loadFallbackIfNeeded(newLocale) + }) + + await loadFallbackIfNeeded($i18n.locale.value) }, }) From f632b7be2ba5daf9f1f1e94aafdec8cf29835371 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9mie=20Pardou?= <571533+jrmi@users.noreply.github.com> Date: Wed, 18 Feb 2026 18:16:19 +0100 Subject: [PATCH 7/8] Fix pending state when quickly switching from one page to another (#4804) --- .../builder/components/PageEditorContent.vue | 136 +++++++++++++++++ .../modules/builder/pages/pageEditor.vue | 141 +++--------------- 2 files changed, 157 insertions(+), 120 deletions(-) create mode 100644 web-frontend/modules/builder/components/PageEditorContent.vue diff --git a/web-frontend/modules/builder/components/PageEditorContent.vue b/web-frontend/modules/builder/components/PageEditorContent.vue new file mode 100644 index 0000000000..06fd40da95 --- /dev/null +++ b/web-frontend/modules/builder/components/PageEditorContent.vue @@ -0,0 +1,136 @@ + + + diff --git a/web-frontend/modules/builder/pages/pageEditor.vue b/web-frontend/modules/builder/pages/pageEditor.vue index 12f1a625c3..97535ce490 100644 --- a/web-frontend/modules/builder/pages/pageEditor.vue +++ b/web-frontend/modules/builder/pages/pageEditor.vue @@ -1,33 +1,21 @@