From fee3b2fd4597396a63557a333bf0f73d861b9c0f Mon Sep 17 00:00:00 2001 From: Timothy Sullivan Date: Thu, 7 May 2026 13:30:53 -0700 Subject: [PATCH 01/41] [User Storage Service] browser-side client and React hooks --- package.json | 2 + .../packages/lifecycle/browser-mocks/moon.yml | 1 + .../browser-mocks/src/core_setup.mock.ts | 2 + .../browser-mocks/src/core_start.mock.ts | 2 + .../lifecycle/browser-mocks/tsconfig.json | 1 + src/core/packages/lifecycle/browser/moon.yml | 1 + .../lifecycle/browser/src/core_setup.ts | 3 + .../lifecycle/browser/src/core_start.ts | 3 + .../packages/lifecycle/browser/tsconfig.json | 1 + .../packages/root/browser-internal/moon.yml | 1 + .../root/browser-internal/src/core_system.ts | 8 + .../root/browser-internal/tsconfig.json | 1 + .../root/server-internal/src/server.ts | 1 + .../user-storage/browser-internal/index.ts | 11 ++ .../browser-internal/jest.config.js | 14 ++ .../browser-internal/kibana.jsonc | 9 ++ .../user-storage/browser-internal/moon.yml | 38 +++++ .../browser-internal/package.json | 6 + .../browser-internal/src/index.ts | 14 ++ .../src/user_storage_api.test.ts | 56 +++++++ .../browser-internal/src/user_storage_api.ts | 37 +++++ .../src/user_storage_client.test.ts | 145 ++++++++++++++++++ .../src/user_storage_client.ts | 103 +++++++++++++ .../src/user_storage_service.test.ts | 74 +++++++++ .../src/user_storage_service.ts | 54 +++++++ .../browser-internal/tsconfig.json | 23 +++ .../user-storage/browser-mocks/index.ts | 10 ++ .../user-storage/browser-mocks/jest.config.js | 14 ++ .../user-storage/browser-mocks/kibana.jsonc | 10 ++ .../user-storage/browser-mocks/moon.yml | 35 +++++ .../user-storage/browser-mocks/package.json | 6 + .../browser-mocks/src/client.mock.ts | 23 +++ .../src/service_contract.mock.ts | 17 ++ .../src/user_storage_service.mock.ts | 27 ++++ .../user-storage/browser-mocks/tsconfig.json | 20 +++ .../packages/user-storage/browser/index.ts | 16 ++ .../user-storage/browser/jest.config.js | 14 ++ .../user-storage/browser/kibana.jsonc | 9 ++ .../packages/user-storage/browser/moon.yml | 35 +++++ .../user-storage/browser/package.json | 6 + .../user-storage/browser/src/index.ts | 14 ++ .../user-storage/browser/src/types.ts | 76 +++++++++ .../browser/src/use_user_storage.test.tsx | 120 +++++++++++++++ .../browser/src/use_user_storage.ts | 64 ++++++++ .../browser/src/user_storage_context.ts | 13 ++ .../browser/src/user_storage_provider.tsx | 27 ++++ .../user-storage/browser/tsconfig.json | 20 +++ tsconfig.base.json | 6 + yarn.lock | 8 + 49 files changed, 1201 insertions(+) create mode 100644 src/core/packages/user-storage/browser-internal/index.ts create mode 100644 src/core/packages/user-storage/browser-internal/jest.config.js create mode 100644 src/core/packages/user-storage/browser-internal/kibana.jsonc create mode 100644 src/core/packages/user-storage/browser-internal/moon.yml create mode 100644 src/core/packages/user-storage/browser-internal/package.json create mode 100644 src/core/packages/user-storage/browser-internal/src/index.ts create mode 100644 src/core/packages/user-storage/browser-internal/src/user_storage_api.test.ts create mode 100644 src/core/packages/user-storage/browser-internal/src/user_storage_api.ts create mode 100644 src/core/packages/user-storage/browser-internal/src/user_storage_client.test.ts create mode 100644 src/core/packages/user-storage/browser-internal/src/user_storage_client.ts create mode 100644 src/core/packages/user-storage/browser-internal/src/user_storage_service.test.ts create mode 100644 src/core/packages/user-storage/browser-internal/src/user_storage_service.ts create mode 100644 src/core/packages/user-storage/browser-internal/tsconfig.json create mode 100644 src/core/packages/user-storage/browser-mocks/index.ts create mode 100644 src/core/packages/user-storage/browser-mocks/jest.config.js create mode 100644 src/core/packages/user-storage/browser-mocks/kibana.jsonc create mode 100644 src/core/packages/user-storage/browser-mocks/moon.yml create mode 100644 src/core/packages/user-storage/browser-mocks/package.json create mode 100644 src/core/packages/user-storage/browser-mocks/src/client.mock.ts create mode 100644 src/core/packages/user-storage/browser-mocks/src/service_contract.mock.ts create mode 100644 src/core/packages/user-storage/browser-mocks/src/user_storage_service.mock.ts create mode 100644 src/core/packages/user-storage/browser-mocks/tsconfig.json create mode 100644 src/core/packages/user-storage/browser/index.ts create mode 100644 src/core/packages/user-storage/browser/jest.config.js create mode 100644 src/core/packages/user-storage/browser/kibana.jsonc create mode 100644 src/core/packages/user-storage/browser/moon.yml create mode 100644 src/core/packages/user-storage/browser/package.json create mode 100644 src/core/packages/user-storage/browser/src/index.ts create mode 100644 src/core/packages/user-storage/browser/src/types.ts create mode 100644 src/core/packages/user-storage/browser/src/use_user_storage.test.tsx create mode 100644 src/core/packages/user-storage/browser/src/use_user_storage.ts create mode 100644 src/core/packages/user-storage/browser/src/user_storage_context.ts create mode 100644 src/core/packages/user-storage/browser/src/user_storage_provider.tsx create mode 100644 src/core/packages/user-storage/browser/tsconfig.json diff --git a/package.json b/package.json index 65390952e1635..7a73fb379efaa 100644 --- a/package.json +++ b/package.json @@ -529,6 +529,8 @@ "@kbn/core-user-settings-server": "link:src/core/packages/user-settings/server", "@kbn/core-user-settings-server-internal": "link:src/core/packages/user-settings/server-internal", "@kbn/core-user-settings-server-mocks": "link:src/core/packages/user-settings/server-mocks", + "@kbn/core-user-storage-browser": "link:src/core/packages/user-storage/browser", + "@kbn/core-user-storage-browser-internal": "link:src/core/packages/user-storage/browser-internal", "@kbn/core-user-storage-common": "link:src/core/packages/user-storage/common", "@kbn/core-user-storage-server": "link:src/core/packages/user-storage/server", "@kbn/core-user-storage-server-internal": "link:src/core/packages/user-storage/server-internal", diff --git a/src/core/packages/lifecycle/browser-mocks/moon.yml b/src/core/packages/lifecycle/browser-mocks/moon.yml index dc3b1375236bd..a4610b0bfd0f2 100644 --- a/src/core/packages/lifecycle/browser-mocks/moon.yml +++ b/src/core/packages/lifecycle/browser-mocks/moon.yml @@ -37,6 +37,7 @@ dependsOn: - '@kbn/core-rendering-browser-mocks' - '@kbn/core-pricing-browser-mocks' - '@kbn/core-di-mocks' + - '@kbn/core-user-storage-browser-mocks' - '@kbn/lazy-object' tags: - shared-browser diff --git a/src/core/packages/lifecycle/browser-mocks/src/core_setup.mock.ts b/src/core/packages/lifecycle/browser-mocks/src/core_setup.mock.ts index 39b5d23b1efbb..08f992cd5bc40 100644 --- a/src/core/packages/lifecycle/browser-mocks/src/core_setup.mock.ts +++ b/src/core/packages/lifecycle/browser-mocks/src/core_setup.mock.ts @@ -24,6 +24,7 @@ import { userProfileServiceMock } from '@kbn/core-user-profile-browser-mocks'; import { createCoreStartMock } from './core_start.mock'; import { coreFeatureFlagsMock } from '@kbn/core-feature-flags-browser-mocks'; import { chromeServiceMock } from '@kbn/core-chrome-browser-mocks'; +import { userStorageServiceMock } from '@kbn/core-user-storage-browser-mocks'; import { lazyObject } from '@kbn/lazy-object'; export function createCoreSetupMock({ @@ -56,6 +57,7 @@ export function createCoreSetupMock({ theme: themeServiceMock.createSetupContract(), security: securityServiceMock.createSetup(), userProfile: userProfileServiceMock.createSetup(), + userStorage: userStorageServiceMock.createSetupContract(), plugins: lazyObject({ onSetup: jest.fn(), onStart: jest.fn(), diff --git a/src/core/packages/lifecycle/browser-mocks/src/core_start.mock.ts b/src/core/packages/lifecycle/browser-mocks/src/core_start.mock.ts index 9fc8938f61bab..11ac7a52ee1b6 100644 --- a/src/core/packages/lifecycle/browser-mocks/src/core_start.mock.ts +++ b/src/core/packages/lifecycle/browser-mocks/src/core_start.mock.ts @@ -27,6 +27,7 @@ import { userProfileServiceMock } from '@kbn/core-user-profile-browser-mocks'; import { renderingServiceMock } from '@kbn/core-rendering-browser-mocks'; import { coreFeatureFlagsMock } from '@kbn/core-feature-flags-browser-mocks'; import { pricingServiceMock } from '@kbn/core-pricing-browser-mocks'; +import { userStorageServiceMock } from '@kbn/core-user-storage-browser-mocks'; import { lazyObject } from '@kbn/lazy-object'; export function createCoreStartMock({ basePath = '' } = {}) { @@ -52,6 +53,7 @@ export function createCoreStartMock({ basePath = '' } = {}) { userProfile: userProfileServiceMock.createStart(), rendering: renderingServiceMock.create(), pricing: pricingServiceMock.createStartContract(), + userStorage: userStorageServiceMock.createStartContract(), plugins: lazyObject({ onStart: jest.fn(), }), diff --git a/src/core/packages/lifecycle/browser-mocks/tsconfig.json b/src/core/packages/lifecycle/browser-mocks/tsconfig.json index 87c10fe292a78..2fab96184f6f1 100644 --- a/src/core/packages/lifecycle/browser-mocks/tsconfig.json +++ b/src/core/packages/lifecycle/browser-mocks/tsconfig.json @@ -32,6 +32,7 @@ "@kbn/core-rendering-browser-mocks", "@kbn/core-pricing-browser-mocks", "@kbn/core-di-mocks", + "@kbn/core-user-storage-browser-mocks", "@kbn/lazy-object", ], "exclude": [ diff --git a/src/core/packages/lifecycle/browser/moon.yml b/src/core/packages/lifecycle/browser/moon.yml index 280d0ac2ed7e2..b225c2490e35c 100644 --- a/src/core/packages/lifecycle/browser/moon.yml +++ b/src/core/packages/lifecycle/browser/moon.yml @@ -37,6 +37,7 @@ dependsOn: - '@kbn/core-feature-flags-browser' - '@kbn/core-rendering-browser' - '@kbn/core-pricing-browser' + - '@kbn/core-user-storage-browser' - '@kbn/core-di' tags: - shared-browser diff --git a/src/core/packages/lifecycle/browser/src/core_setup.ts b/src/core/packages/lifecycle/browser/src/core_setup.ts index ce55d19ad3737..2deb91ccd1b23 100644 --- a/src/core/packages/lifecycle/browser/src/core_setup.ts +++ b/src/core/packages/lifecycle/browser/src/core_setup.ts @@ -22,6 +22,7 @@ import type { CustomBrandingSetup } from '@kbn/core-custom-branding-browser'; import type { PluginsServiceSetup } from '@kbn/core-plugins-contracts-browser'; import type { SecurityServiceSetup } from '@kbn/core-security-browser'; import type { UserProfileServiceSetup } from '@kbn/core-user-profile-browser'; +import type { IUserStorageClient } from '@kbn/core-user-storage-browser'; import type { CoreStart } from './core_start'; /** @@ -72,6 +73,8 @@ export interface CoreSetup = {}, TStar security: SecurityServiceSetup; /** {@link UserProfileServiceSetup} */ userProfile: UserProfileServiceSetup; + /** {@link IUserStorageClient} */ + userStorage: IUserStorageClient; /** {@link StartServicesAccessor} */ getStartServices: StartServicesAccessor; } diff --git a/src/core/packages/lifecycle/browser/src/core_start.ts b/src/core/packages/lifecycle/browser/src/core_start.ts index f1b5d9c1ecf63..f6a1eebf719e8 100644 --- a/src/core/packages/lifecycle/browser/src/core_start.ts +++ b/src/core/packages/lifecycle/browser/src/core_start.ts @@ -28,6 +28,7 @@ import type { SecurityServiceStart } from '@kbn/core-security-browser'; import type { RenderingService } from '@kbn/core-rendering-browser'; import type { UserProfileServiceStart } from '@kbn/core-user-profile-browser'; import type { FeatureFlagsStart } from '@kbn/core-feature-flags-browser'; +import type { IUserStorageClient } from '@kbn/core-user-storage-browser'; /** * Core services exposed to the `Plugin` start lifecycle @@ -82,6 +83,8 @@ export interface CoreStart { security: SecurityServiceStart; /** {@link UserProfileServiceStart} */ userProfile: UserProfileServiceStart; + /** {@link IUserStorageClient} */ + userStorage: IUserStorageClient; /** {@link RenderingService} */ rendering: RenderingService; } diff --git a/src/core/packages/lifecycle/browser/tsconfig.json b/src/core/packages/lifecycle/browser/tsconfig.json index 9d98a96e6d932..19c094ac4eeaa 100644 --- a/src/core/packages/lifecycle/browser/tsconfig.json +++ b/src/core/packages/lifecycle/browser/tsconfig.json @@ -32,6 +32,7 @@ "@kbn/core-feature-flags-browser", "@kbn/core-rendering-browser", "@kbn/core-pricing-browser", + "@kbn/core-user-storage-browser", "@kbn/core-di", ], "exclude": [ diff --git a/src/core/packages/root/browser-internal/moon.yml b/src/core/packages/root/browser-internal/moon.yml index d1f72c96965d7..9a94eb5de4cd3 100644 --- a/src/core/packages/root/browser-internal/moon.yml +++ b/src/core/packages/root/browser-internal/moon.yml @@ -79,6 +79,7 @@ dependsOn: - '@kbn/core-di-browser-internal' - '@kbn/core-di-mocks' - '@kbn/core-notifications-browser' + - '@kbn/core-user-storage-browser-internal' tags: - shared-browser - package diff --git a/src/core/packages/root/browser-internal/src/core_system.ts b/src/core/packages/root/browser-internal/src/core_system.ts index 0537abd2c7149..6e6bb4094994c 100644 --- a/src/core/packages/root/browser-internal/src/core_system.ts +++ b/src/core/packages/root/browser-internal/src/core_system.ts @@ -43,6 +43,7 @@ import { PricingService } from '@kbn/core-pricing-browser-internal'; import { CustomBrandingService } from '@kbn/core-custom-branding-browser-internal'; import { SecurityService } from '@kbn/core-security-browser-internal'; import { UserProfileService } from '@kbn/core-user-profile-browser-internal'; +import { UserStorageService } from '@kbn/core-user-storage-browser-internal'; import { version as REACT_VERSION } from 'react'; import { muteLegacyRootWarning } from '@kbn/react-mute-legacy-root-warning'; import { CoreInjectionService } from '@kbn/core-di-browser-internal'; @@ -115,6 +116,7 @@ export class CoreSystem { private readonly customBranding: CustomBrandingService; private readonly security: SecurityService; private readonly userProfile: UserProfileService; + private readonly userStorage: UserStorageService; private readonly pricing: PricingService; private fatalErrorsSetup: FatalErrorsSetup | null = null; private overlayNavigationSubscription: Subscription | undefined; @@ -160,6 +162,7 @@ export class CoreSystem { this.httpRateLimiter = new HttpRateLimiterService(); this.uiSettings = new UiSettingsService(); this.settings = new SettingsService(); + this.userStorage = new UserStorageService(); this.overlay = new OverlayService(); this.chrome = new ChromeService({ browserSupportsCsp, @@ -270,6 +273,7 @@ export class CoreSystem { const chrome = this.chrome.setup({ analytics }); const uiSettings = this.uiSettings.setup({ http, injectedMetadata }); const settings = this.settings.setup({ http, injectedMetadata }); + const userStorage = this.userStorage.setup({ http, injectedMetadata }); const notifications = this.notifications.setup({ uiSettings, analytics }); const customBranding = this.customBranding.setup({ injectedMetadata }); const application = this.application.setup({ http, analytics }); @@ -289,6 +293,7 @@ export class CoreSystem { theme, uiSettings, settings, + userStorage, executionContext, customBranding, security, @@ -323,6 +328,7 @@ export class CoreSystem { const injection = this.injection.start(); const uiSettings = this.uiSettings.start(); const settings = this.settings.start(); + const userStorage = this.userStorage.start(); const docLinks = this.docLinks.start({ injectedMetadata }); const http = this.http.start(); const i18n = this.i18n.start(); @@ -439,6 +445,7 @@ export class CoreSystem { overlays, uiSettings, settings, + userStorage, fatalErrors, deprecations, customBranding, @@ -509,6 +516,7 @@ export class CoreSystem { this.integrations.stop(); this.uiSettings.stop(); this.settings.stop(); + this.userStorage.stop(); this.chrome.stop(); this.i18n.stop(); this.application.stop(); diff --git a/src/core/packages/root/browser-internal/tsconfig.json b/src/core/packages/root/browser-internal/tsconfig.json index 226a8d983292f..3633d1a13ea8e 100644 --- a/src/core/packages/root/browser-internal/tsconfig.json +++ b/src/core/packages/root/browser-internal/tsconfig.json @@ -74,6 +74,7 @@ "@kbn/core-di-browser-internal", "@kbn/core-di-mocks", "@kbn/core-notifications-browser", + "@kbn/core-user-storage-browser-internal", ], "exclude": [ "target/**/*", diff --git a/src/core/packages/root/server-internal/src/server.ts b/src/core/packages/root/server-internal/src/server.ts index 0c13d1adaad99..3ea6441524970 100644 --- a/src/core/packages/root/server-internal/src/server.ts +++ b/src/core/packages/root/server-internal/src/server.ts @@ -618,6 +618,7 @@ export class Server { this.rendering.start({ featureFlags: featureFlagsStart, + userStorage: userStorageStart, }); this.coreStart = { diff --git a/src/core/packages/user-storage/browser-internal/index.ts b/src/core/packages/user-storage/browser-internal/index.ts new file mode 100644 index 0000000000000..9a1bf1f33c9c4 --- /dev/null +++ b/src/core/packages/user-storage/browser-internal/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +export { UserStorageService, UserStorageClient, UserStorageApi } from './src'; +export type { UserStorageServiceDeps, UserStorageClientParams } from './src'; diff --git a/src/core/packages/user-storage/browser-internal/jest.config.js b/src/core/packages/user-storage/browser-internal/jest.config.js new file mode 100644 index 0000000000000..6ae8a1846b332 --- /dev/null +++ b/src/core/packages/user-storage/browser-internal/jest.config.js @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../../../../..', + roots: ['/src/core/packages/user-storage/browser-internal'], +}; diff --git a/src/core/packages/user-storage/browser-internal/kibana.jsonc b/src/core/packages/user-storage/browser-internal/kibana.jsonc new file mode 100644 index 0000000000000..be5584eb048fa --- /dev/null +++ b/src/core/packages/user-storage/browser-internal/kibana.jsonc @@ -0,0 +1,9 @@ +{ + "type": "shared-browser", + "id": "@kbn/core-user-storage-browser-internal", + "owner": [ + "@elastic/appex-sharedux" + ], + "group": "platform", + "visibility": "private" +} diff --git a/src/core/packages/user-storage/browser-internal/moon.yml b/src/core/packages/user-storage/browser-internal/moon.yml new file mode 100644 index 0000000000000..660f2e4bbae3b --- /dev/null +++ b/src/core/packages/user-storage/browser-internal/moon.yml @@ -0,0 +1,38 @@ +# This file is generated by the @kbn/moon package. Any manual edits will be erased! +# To extend this, write your extensions/overrides to 'moon.extend.yml' +# then regenerate this file with: 'node scripts/regenerate_moon_projects.js --update --filter @kbn/core-user-storage-browser-internal' + +$schema: https://moonrepo.dev/schemas/project.json +id: '@kbn/core-user-storage-browser-internal' +layer: unknown +owners: + defaultOwner: '@elastic/appex-sharedux' +toolchains: + default: node +language: typescript +project: + title: '@kbn/core-user-storage-browser-internal' + description: Moon project for @kbn/core-user-storage-browser-internal + channel: '' + owner: '@elastic/appex-sharedux' + sourceRoot: src/core/packages/user-storage/browser-internal +dependsOn: + - '@kbn/core-http-browser-internal' + - '@kbn/core-http-browser-mocks' + - '@kbn/core-injected-metadata-browser-internal' + - '@kbn/core-injected-metadata-browser-mocks' + - '@kbn/core-user-storage-browser' +tags: + - shared-browser + - package + - prod + - group-platform + - private + - jest-unit-tests +fileGroups: + src: + - '**/*.ts' + - '!target/**/*' + jest-config: + - jest.config.js +tasks: {} diff --git a/src/core/packages/user-storage/browser-internal/package.json b/src/core/packages/user-storage/browser-internal/package.json new file mode 100644 index 0000000000000..8206c5d4324f5 --- /dev/null +++ b/src/core/packages/user-storage/browser-internal/package.json @@ -0,0 +1,6 @@ +{ + "name": "@kbn/core-user-storage-browser-internal", + "private": true, + "version": "1.0.0", + "license": "Elastic License 2.0 OR AGPL-3.0-only OR SSPL-1.0" +} diff --git a/src/core/packages/user-storage/browser-internal/src/index.ts b/src/core/packages/user-storage/browser-internal/src/index.ts new file mode 100644 index 0000000000000..397c8215990e1 --- /dev/null +++ b/src/core/packages/user-storage/browser-internal/src/index.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +export { UserStorageService } from './user_storage_service'; +export type { UserStorageServiceDeps } from './user_storage_service'; +export { UserStorageClient } from './user_storage_client'; +export type { UserStorageClientParams } from './user_storage_client'; +export { UserStorageApi } from './user_storage_api'; diff --git a/src/core/packages/user-storage/browser-internal/src/user_storage_api.test.ts b/src/core/packages/user-storage/browser-internal/src/user_storage_api.test.ts new file mode 100644 index 0000000000000..e0fdf044c91c0 --- /dev/null +++ b/src/core/packages/user-storage/browser-internal/src/user_storage_api.test.ts @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { httpServiceMock } from '@kbn/core-http-browser-mocks'; +import { UserStorageApi } from './user_storage_api'; + +describe('UserStorageApi', () => { + const http = httpServiceMock.createSetupContract(); + let api: UserStorageApi; + + beforeEach(() => { + http.get.mockReset(); + http.put.mockReset(); + http.delete.mockReset(); + api = new UserStorageApi(http); + }); + + it('GETs /internal/user_storage for getAll', async () => { + http.get.mockResolvedValue({ foo: 1 }); + + await expect(api.getAll()).resolves.toEqual({ foo: 1 }); + expect(http.get).toHaveBeenCalledWith('/internal/user_storage'); + }); + + it('PUTs /internal/user_storage/{key} with a value-wrapped body', async () => { + http.put.mockResolvedValue(undefined); + + await api.set('navigation:layout', { hidden: ['discover'] }); + + expect(http.put).toHaveBeenCalledWith('/internal/user_storage/navigation%3Alayout', { + body: JSON.stringify({ value: { hidden: ['discover'] } }), + }); + }); + + it('DELETEs /internal/user_storage/{key} for remove', async () => { + http.delete.mockResolvedValue(undefined); + + await api.remove('navigation:layout'); + + expect(http.delete).toHaveBeenCalledWith('/internal/user_storage/navigation%3Alayout'); + }); + + it('encodes special characters in keys', async () => { + http.put.mockResolvedValue(undefined); + + await api.set('a/b c', 1); + + expect(http.put).toHaveBeenCalledWith('/internal/user_storage/a%2Fb%20c', expect.any(Object)); + }); +}); diff --git a/src/core/packages/user-storage/browser-internal/src/user_storage_api.ts b/src/core/packages/user-storage/browser-internal/src/user_storage_api.ts new file mode 100644 index 0000000000000..636ebbcae9fa3 --- /dev/null +++ b/src/core/packages/user-storage/browser-internal/src/user_storage_api.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { InternalHttpSetup } from '@kbn/core-http-browser-internal'; + +const BASE_PATH = '/internal/user_storage'; + +/** + * Thin HTTP wrapper over the user-storage internal routes. Each method maps + * to a single round-trip; callers (the {@link UserStorageClient}) own caching + * and observable state. + * + * @internal + */ +export class UserStorageApi { + constructor(private readonly http: InternalHttpSetup) {} + + public async getAll(): Promise> { + return this.http.get>(BASE_PATH); + } + + public async set(key: string, value: unknown): Promise { + await this.http.put(`${BASE_PATH}/${encodeURIComponent(key)}`, { + body: JSON.stringify({ value }), + }); + } + + public async remove(key: string): Promise { + await this.http.delete(`${BASE_PATH}/${encodeURIComponent(key)}`); + } +} diff --git a/src/core/packages/user-storage/browser-internal/src/user_storage_client.test.ts b/src/core/packages/user-storage/browser-internal/src/user_storage_client.test.ts new file mode 100644 index 0000000000000..83217d2252cc1 --- /dev/null +++ b/src/core/packages/user-storage/browser-internal/src/user_storage_client.test.ts @@ -0,0 +1,145 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { Subject, firstValueFrom, lastValueFrom, take, toArray } from 'rxjs'; +import { UserStorageClient } from './user_storage_client'; +import type { UserStorageApi } from './user_storage_api'; + +const apiMock = (): jest.Mocked => + ({ + getAll: jest.fn(), + set: jest.fn(), + remove: jest.fn(), + } as unknown as jest.Mocked); + +const buildClient = (initialValues: Record = {}) => { + const api = apiMock(); + const done$ = new Subject(); + const client = new UserStorageClient({ api, initialValues, done$ }); + return { api, done$, client }; +}; + +describe('UserStorageClient', () => { + describe('get / getAll', () => { + it('returns cached values seeded from initialValues', () => { + const { client } = buildClient({ a: 1, b: 'two' }); + + expect(client.get('a')).toBe(1); + expect(client.get('b')).toBe('two'); + expect(client.getAll()).toEqual({ a: 1, b: 'two' }); + }); + + it('returns the defaultValue when the key is not cached', () => { + const { client } = buildClient({}); + + expect(client.get('missing', 'fallback')).toBe('fallback'); + }); + + it('clones getAll output to prevent external mutation of cache', () => { + const { client } = buildClient({ list: [1, 2] }); + + const all = client.getAll() as { list: number[] }; + all.list.push(3); + + expect(client.get('list')).toEqual([1, 2]); + }); + }); + + describe('get$', () => { + it('emits the current value immediately and on subsequent updates', async () => { + const { client, api } = buildClient({ key: 'first' }); + api.set.mockResolvedValue(undefined); + + const emissions = lastValueFrom(client.get$('key').pipe(take(2), toArray())); + + await client.set('key', 'second'); + + expect(await emissions).toEqual(['first', 'second']); + }); + + it('does not emit for unrelated keys', async () => { + const { client, api } = buildClient({}); + api.set.mockResolvedValue(undefined); + + const first = firstValueFrom(client.get$('a')); + await client.set('b', 99); + + // only the initial emission resolves; if `b` had leaked we'd see 99. + await expect(first).resolves.toBeUndefined(); + }); + }); + + describe('set', () => { + it('updates cache and emits on update$ after a successful HTTP call', async () => { + const { client, api } = buildClient({ key: 'old' }); + api.set.mockResolvedValue(undefined); + + const updates = firstValueFrom(client.getUpdate$()); + + await client.set('key', 'new'); + + expect(client.get('key')).toBe('new'); + await expect(updates).resolves.toEqual({ key: 'key', newValue: 'new', oldValue: 'old' }); + }); + + it('does not mutate cache or emit when the HTTP call fails, and rejects', async () => { + const { client, api } = buildClient({ key: 'old' }); + api.set.mockRejectedValue(new Error('boom')); + + const errors = firstValueFrom(client.getUpdateErrors$()); + + await expect(client.set('key', 'new')).rejects.toThrow('boom'); + expect(client.get('key')).toBe('old'); + await expect(errors).resolves.toEqual(expect.any(Error)); + }); + }); + + describe('remove', () => { + it('clears cache and emits on update$ after a successful HTTP call', async () => { + const { client, api } = buildClient({ key: 'old' }); + api.remove.mockResolvedValue(undefined); + + const updates = firstValueFrom(client.getUpdate$()); + + await client.remove('key'); + + expect(client.get('key')).toBeUndefined(); + await expect(updates).resolves.toEqual({ + key: 'key', + newValue: undefined, + oldValue: 'old', + }); + }); + + it('rejects and emits on errors$ when the HTTP call fails', async () => { + const { client, api } = buildClient({ key: 'old' }); + api.remove.mockRejectedValue(new Error('nope')); + + const errors = firstValueFrom(client.getUpdateErrors$()); + + await expect(client.remove('key')).rejects.toThrow('nope'); + expect(client.get('key')).toBe('old'); + await expect(errors).resolves.toEqual(expect.any(Error)); + }); + }); + + describe('done$', () => { + it('completes update$ and updateErrors$ when done$ completes', async () => { + const { client, done$ } = buildClient({}); + + const update$ = client.getUpdate$(); + const errors$ = client.getUpdateErrors$(); + + done$.complete(); + + await expect(lastValueFrom(update$, { defaultValue: 'done' })).resolves.toBe('done'); + await expect(lastValueFrom(errors$, { defaultValue: 'done' })).resolves.toBe('done'); + }); + }); +}); diff --git a/src/core/packages/user-storage/browser-internal/src/user_storage_client.ts b/src/core/packages/user-storage/browser-internal/src/user_storage_client.ts new file mode 100644 index 0000000000000..c898214aa0709 --- /dev/null +++ b/src/core/packages/user-storage/browser-internal/src/user_storage_client.ts @@ -0,0 +1,103 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { cloneDeep } from 'lodash'; +import { Subject, concat, defer, of, type Observable } from 'rxjs'; +import { filter, map } from 'rxjs'; + +import type { IUserStorageClient, UserStorageUpdate } from '@kbn/core-user-storage-browser'; +import type { UserStorageApi } from './user_storage_api'; + +export interface UserStorageClientParams { + api: UserStorageApi; + initialValues: Record; + done$: Observable; +} + +/** + * Browser-side {@link IUserStorageClient}. Holds a synchronous in-memory + * cache seeded from server-injected metadata. Mirrors the read/observable + * surface of `UiSettingsClientCommon`, simplified for user-storage's coarser + * write semantics (no batching, no validation roundtrip). + * + * @internal + */ +export class UserStorageClient implements IUserStorageClient { + private cache: Record; + private readonly api: UserStorageApi; + private readonly update$ = new Subject(); + private readonly updateErrors$ = new Subject(); + + constructor({ api, initialValues, done$ }: UserStorageClientParams) { + this.api = api; + this.cache = cloneDeep(initialValues); + + done$.subscribe({ + complete: () => { + this.update$.complete(); + this.updateErrors$.complete(); + }, + }); + } + + public get(key: string, defaultValue?: T): T { + const cached = this.cache[key]; + return (cached !== undefined ? cached : defaultValue) as T; + } + + public get$(key: string, defaultValue?: T): Observable { + return concat( + defer(() => of(this.get(key, defaultValue))), + this.update$.pipe( + filter((u) => u.key === key), + map(() => this.get(key, defaultValue)) + ) + ); + } + + public getAll(): Readonly> { + return cloneDeep(this.cache); + } + + public async set(key: string, value: T): Promise { + try { + await this.api.set(key, value); + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + this.updateErrors$.next(err); + throw err; + } + + const oldValue = this.cache[key]; + this.cache[key] = value; + this.update$.next({ key, newValue: value, oldValue }); + } + + public async remove(key: string): Promise { + try { + await this.api.remove(key); + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + this.updateErrors$.next(err); + throw err; + } + + const oldValue = this.cache[key]; + delete this.cache[key]; + this.update$.next({ key, newValue: undefined, oldValue }); + } + + public getUpdate$(): Observable { + return this.update$.asObservable(); + } + + public getUpdateErrors$(): Observable { + return this.updateErrors$.asObservable(); + } +} diff --git a/src/core/packages/user-storage/browser-internal/src/user_storage_service.test.ts b/src/core/packages/user-storage/browser-internal/src/user_storage_service.test.ts new file mode 100644 index 0000000000000..91dbd8533552c --- /dev/null +++ b/src/core/packages/user-storage/browser-internal/src/user_storage_service.test.ts @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { firstValueFrom, lastValueFrom } from 'rxjs'; +import { httpServiceMock } from '@kbn/core-http-browser-mocks'; +import { injectedMetadataServiceMock } from '@kbn/core-injected-metadata-browser-mocks'; +import { UserStorageService } from './user_storage_service'; + +const buildDeps = (initialValues: Record = {}) => { + const http = httpServiceMock.createSetupContract(); + const injectedMetadata = injectedMetadataServiceMock.createSetupContract(); + injectedMetadata.getUserStorage.mockReturnValue({ values: initialValues }); + return { http, injectedMetadata }; +}; + +describe('UserStorageService', () => { + it('seeds the client cache from injected metadata at setup', () => { + const service = new UserStorageService(); + const client = service.setup(buildDeps({ key: 42 })); + + expect(client.get('key')).toBe(42); + }); + + it('returns the same client from start as setup', () => { + const service = new UserStorageService(); + const setupClient = service.setup(buildDeps()); + + expect(service.start()).toBe(setupClient); + }); + + it('writes through to http.put on set', async () => { + const service = new UserStorageService(); + const deps = buildDeps(); + deps.http.put.mockResolvedValue(undefined); + + const client = service.setup(deps); + + await client.set('navigation:layout', { hidden: ['discover'] }); + + expect(deps.http.put).toHaveBeenCalledWith( + '/internal/user_storage/navigation%3Alayout', + expect.objectContaining({ + body: JSON.stringify({ value: { hidden: ['discover'] } }), + }) + ); + expect(client.get('navigation:layout')).toEqual({ hidden: ['discover'] }); + }); + + it('completes the client observables on stop', async () => { + const service = new UserStorageService(); + const client = service.setup(buildDeps()); + + const update$ = client.getUpdate$(); + const errors$ = client.getUpdateErrors$(); + + service.stop(); + + await expect(lastValueFrom(update$, { defaultValue: 'done' })).resolves.toBe('done'); + await expect(lastValueFrom(errors$, { defaultValue: 'done' })).resolves.toBe('done'); + }); + + it('emits the seeded value as the first observable emission', async () => { + const service = new UserStorageService(); + const client = service.setup(buildDeps({ key: 'seed' })); + + await expect(firstValueFrom(client.get$('key'))).resolves.toBe('seed'); + }); +}); diff --git a/src/core/packages/user-storage/browser-internal/src/user_storage_service.ts b/src/core/packages/user-storage/browser-internal/src/user_storage_service.ts new file mode 100644 index 0000000000000..bcdf42fc0ff31 --- /dev/null +++ b/src/core/packages/user-storage/browser-internal/src/user_storage_service.ts @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { Subject } from 'rxjs'; + +import type { InternalInjectedMetadataSetup } from '@kbn/core-injected-metadata-browser-internal'; +import type { InternalHttpSetup } from '@kbn/core-http-browser-internal'; +import type { IUserStorageClient } from '@kbn/core-user-storage-browser'; + +import { UserStorageApi } from './user_storage_api'; +import { UserStorageClient } from './user_storage_client'; + +export interface UserStorageServiceDeps { + http: InternalHttpSetup; + injectedMetadata: InternalInjectedMetadataSetup; +} + +/** + * Browser core service that owns the lifecycle of the {@link IUserStorageClient}. + * + * @internal + */ +export class UserStorageService { + private client?: UserStorageClient; + private readonly done$ = new Subject(); + + public setup({ http, injectedMetadata }: UserStorageServiceDeps): IUserStorageClient { + const api = new UserStorageApi(http); + const initialValues = injectedMetadata.getUserStorage().values; + + this.client = new UserStorageClient({ + api, + initialValues, + done$: this.done$, + }); + + return this.client; + } + + public start(): IUserStorageClient { + return this.client!; + } + + public stop() { + this.done$.next(); + this.done$.complete(); + } +} diff --git a/src/core/packages/user-storage/browser-internal/tsconfig.json b/src/core/packages/user-storage/browser-internal/tsconfig.json new file mode 100644 index 0000000000000..ef97e584b55b3 --- /dev/null +++ b/src/core/packages/user-storage/browser-internal/tsconfig.json @@ -0,0 +1,23 @@ +{ + "extends": "@kbn/tsconfig-base/tsconfig.json", + "compilerOptions": { + "outDir": "target/types", + "types": [ + "jest", + "node" + ] + }, + "include": [ + "**/*.ts" + ], + "kbn_references": [ + "@kbn/core-http-browser-internal", + "@kbn/core-http-browser-mocks", + "@kbn/core-injected-metadata-browser-internal", + "@kbn/core-injected-metadata-browser-mocks", + "@kbn/core-user-storage-browser" + ], + "exclude": [ + "target/**/*" + ] +} diff --git a/src/core/packages/user-storage/browser-mocks/index.ts b/src/core/packages/user-storage/browser-mocks/index.ts new file mode 100644 index 0000000000000..8d2cfaefa423a --- /dev/null +++ b/src/core/packages/user-storage/browser-mocks/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +export { userStorageServiceMock } from './src/user_storage_service.mock'; diff --git a/src/core/packages/user-storage/browser-mocks/jest.config.js b/src/core/packages/user-storage/browser-mocks/jest.config.js new file mode 100644 index 0000000000000..327e5df96bf9e --- /dev/null +++ b/src/core/packages/user-storage/browser-mocks/jest.config.js @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../../../../..', + roots: ['/src/core/packages/user-storage/browser-mocks'], +}; diff --git a/src/core/packages/user-storage/browser-mocks/kibana.jsonc b/src/core/packages/user-storage/browser-mocks/kibana.jsonc new file mode 100644 index 0000000000000..508212d640841 --- /dev/null +++ b/src/core/packages/user-storage/browser-mocks/kibana.jsonc @@ -0,0 +1,10 @@ +{ + "type": "shared-browser", + "id": "@kbn/core-user-storage-browser-mocks", + "owner": [ + "@elastic/appex-sharedux" + ], + "group": "platform", + "visibility": "shared", + "devOnly": true +} diff --git a/src/core/packages/user-storage/browser-mocks/moon.yml b/src/core/packages/user-storage/browser-mocks/moon.yml new file mode 100644 index 0000000000000..799504a1fe5ce --- /dev/null +++ b/src/core/packages/user-storage/browser-mocks/moon.yml @@ -0,0 +1,35 @@ +# This file is generated by the @kbn/moon package. Any manual edits will be erased! +# To extend this, write your extensions/overrides to 'moon.extend.yml' +# then regenerate this file with: 'node scripts/regenerate_moon_projects.js --update --filter @kbn/core-user-storage-browser-mocks' + +$schema: https://moonrepo.dev/schemas/project.json +id: '@kbn/core-user-storage-browser-mocks' +layer: unknown +owners: + defaultOwner: '@elastic/appex-sharedux' +toolchains: + default: node +language: typescript +project: + title: '@kbn/core-user-storage-browser-mocks' + description: Moon project for @kbn/core-user-storage-browser-mocks + channel: '' + owner: '@elastic/appex-sharedux' + sourceRoot: src/core/packages/user-storage/browser-mocks +dependsOn: + - '@kbn/core-user-storage-browser' + - '@kbn/lazy-object' +tags: + - shared-browser + - package + - dev + - group-platform + - shared + - jest-unit-tests +fileGroups: + src: + - '**/*.ts' + - '!target/**/*' + jest-config: + - jest.config.js +tasks: {} diff --git a/src/core/packages/user-storage/browser-mocks/package.json b/src/core/packages/user-storage/browser-mocks/package.json new file mode 100644 index 0000000000000..4d2e67e693494 --- /dev/null +++ b/src/core/packages/user-storage/browser-mocks/package.json @@ -0,0 +1,6 @@ +{ + "name": "@kbn/core-user-storage-browser-mocks", + "private": true, + "version": "1.0.0", + "license": "Elastic License 2.0 OR AGPL-3.0-only OR SSPL-1.0" +} diff --git a/src/core/packages/user-storage/browser-mocks/src/client.mock.ts b/src/core/packages/user-storage/browser-mocks/src/client.mock.ts new file mode 100644 index 0000000000000..d7dba7d3db4be --- /dev/null +++ b/src/core/packages/user-storage/browser-mocks/src/client.mock.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { Subject } from 'rxjs'; +import { lazyObject } from '@kbn/lazy-object'; +import type { IUserStorageClient } from '@kbn/core-user-storage-browser'; + +export const clientMock = (): jest.Mocked => + lazyObject({ + get: jest.fn(), + get$: jest.fn().mockReturnValue(new Subject()), + getAll: jest.fn().mockReturnValue({}), + set: jest.fn().mockResolvedValue(undefined), + remove: jest.fn().mockResolvedValue(undefined), + getUpdate$: jest.fn().mockReturnValue(new Subject()), + getUpdateErrors$: jest.fn().mockReturnValue(new Subject()), + }); diff --git a/src/core/packages/user-storage/browser-mocks/src/service_contract.mock.ts b/src/core/packages/user-storage/browser-mocks/src/service_contract.mock.ts new file mode 100644 index 0000000000000..570e87ade37db --- /dev/null +++ b/src/core/packages/user-storage/browser-mocks/src/service_contract.mock.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { lazyObject } from '@kbn/lazy-object'; + +export const serviceContractMock = (): jest.Mocked => + lazyObject({ + setup: jest.fn(), + start: jest.fn(), + stop: jest.fn(), + }); diff --git a/src/core/packages/user-storage/browser-mocks/src/user_storage_service.mock.ts b/src/core/packages/user-storage/browser-mocks/src/user_storage_service.mock.ts new file mode 100644 index 0000000000000..fd7d905b7b0c2 --- /dev/null +++ b/src/core/packages/user-storage/browser-mocks/src/user_storage_service.mock.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { clientMock } from './client.mock'; +import { serviceContractMock } from './service_contract.mock'; + +const createSetupContract = () => clientMock(); +const createStartContract = () => clientMock(); + +const createMock = () => { + const mocked = serviceContractMock(); + mocked.setup.mockReturnValue(createSetupContract()); + mocked.start.mockReturnValue(createStartContract()); + return mocked; +}; + +export const userStorageServiceMock = { + create: createMock, + createSetupContract, + createStartContract, +}; diff --git a/src/core/packages/user-storage/browser-mocks/tsconfig.json b/src/core/packages/user-storage/browser-mocks/tsconfig.json new file mode 100644 index 0000000000000..6e710f25f3fcb --- /dev/null +++ b/src/core/packages/user-storage/browser-mocks/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "@kbn/tsconfig-base/tsconfig.json", + "compilerOptions": { + "outDir": "target/types", + "types": [ + "jest", + "node" + ] + }, + "include": [ + "**/*.ts" + ], + "kbn_references": [ + "@kbn/core-user-storage-browser", + "@kbn/lazy-object" + ], + "exclude": [ + "target/**/*" + ] +} diff --git a/src/core/packages/user-storage/browser/index.ts b/src/core/packages/user-storage/browser/index.ts new file mode 100644 index 0000000000000..abb8725c0133f --- /dev/null +++ b/src/core/packages/user-storage/browser/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +export type { + IUserStorageClient, + UserStorageUpdate, + UserStorageProviderProps, + UserStorageSetter, +} from './src'; +export { UserStorageProvider, useUserStorage, useUserStorageClient } from './src'; diff --git a/src/core/packages/user-storage/browser/jest.config.js b/src/core/packages/user-storage/browser/jest.config.js new file mode 100644 index 0000000000000..9cfd18b0c25b3 --- /dev/null +++ b/src/core/packages/user-storage/browser/jest.config.js @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../../../../..', + roots: ['/src/core/packages/user-storage/browser'], +}; diff --git a/src/core/packages/user-storage/browser/kibana.jsonc b/src/core/packages/user-storage/browser/kibana.jsonc new file mode 100644 index 0000000000000..d90f0e351709d --- /dev/null +++ b/src/core/packages/user-storage/browser/kibana.jsonc @@ -0,0 +1,9 @@ +{ + "type": "shared-browser", + "id": "@kbn/core-user-storage-browser", + "owner": [ + "@elastic/appex-sharedux" + ], + "group": "platform", + "visibility": "shared" +} diff --git a/src/core/packages/user-storage/browser/moon.yml b/src/core/packages/user-storage/browser/moon.yml new file mode 100644 index 0000000000000..ba91604ad6b77 --- /dev/null +++ b/src/core/packages/user-storage/browser/moon.yml @@ -0,0 +1,35 @@ +# This file is generated by the @kbn/moon package. Any manual edits will be erased! +# To extend this, write your extensions/overrides to 'moon.extend.yml' +# then regenerate this file with: 'node scripts/regenerate_moon_projects.js --update --filter @kbn/core-user-storage-browser' + +$schema: https://moonrepo.dev/schemas/project.json +id: '@kbn/core-user-storage-browser' +layer: unknown +owners: + defaultOwner: '@elastic/appex-sharedux' +toolchains: + default: node +language: typescript +project: + title: '@kbn/core-user-storage-browser' + description: Moon project for @kbn/core-user-storage-browser + channel: '' + owner: '@elastic/appex-sharedux' + sourceRoot: src/core/packages/user-storage/browser +dependsOn: + - '@kbn/core-user-storage-common' +tags: + - shared-browser + - package + - prod + - group-platform + - shared + - jest-unit-tests +fileGroups: + src: + - '**/*.ts' + - '**/*.tsx' + - '!target/**/*' + jest-config: + - jest.config.js +tasks: {} diff --git a/src/core/packages/user-storage/browser/package.json b/src/core/packages/user-storage/browser/package.json new file mode 100644 index 0000000000000..394f22357c4c8 --- /dev/null +++ b/src/core/packages/user-storage/browser/package.json @@ -0,0 +1,6 @@ +{ + "name": "@kbn/core-user-storage-browser", + "private": true, + "version": "1.0.0", + "license": "Elastic License 2.0 OR AGPL-3.0-only OR SSPL-1.0" +} diff --git a/src/core/packages/user-storage/browser/src/index.ts b/src/core/packages/user-storage/browser/src/index.ts new file mode 100644 index 0000000000000..1375e9503b600 --- /dev/null +++ b/src/core/packages/user-storage/browser/src/index.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +export type { IUserStorageClient, UserStorageUpdate } from './types'; +export { UserStorageProvider } from './user_storage_provider'; +export type { UserStorageProviderProps } from './user_storage_provider'; +export { useUserStorage, useUserStorageClient } from './use_user_storage'; +export type { UserStorageSetter } from './use_user_storage'; diff --git a/src/core/packages/user-storage/browser/src/types.ts b/src/core/packages/user-storage/browser/src/types.ts new file mode 100644 index 0000000000000..719e025125007 --- /dev/null +++ b/src/core/packages/user-storage/browser/src/types.ts @@ -0,0 +1,76 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { Observable } from 'rxjs'; + +/** + * An update emission published when a stored value changes. + * + * @public + */ +export interface UserStorageUpdate { + key: string; + newValue: T; + oldValue: T | undefined; +} + +/** + * Browser-side user storage client. Returns synchronously from an in-memory + * cache that is seeded from server-injected metadata at first paint, and is + * refreshed by `set` / `remove` after the corresponding HTTP write completes. + * + * Distinct from the server-side `IUserStorageClient` (in + * `@kbn/core-user-storage-common`) which is fully Promise-based. + * + * @public + */ +export interface IUserStorageClient { + /** + * Synchronous read from the local cache. Returns `defaultValue` (or + * `undefined` if not provided) when no cached value exists for the key. + */ + get(key: string, defaultValue?: T): T; + + /** + * Observable that emits the current cached value followed by every future + * value seen for the given key. Suitable for React subscriptions. + */ + get$(key: string, defaultValue?: T): Observable; + + /** + * Returns a clone of every cached key/value pair. + */ + getAll(): Readonly>; + + /** + * Persists a new value via `PUT /internal/user_storage/{key}`. On success + * the local cache is updated and subscribers to `get$` / `getUpdate$` are + * notified. On HTTP failure the cache is left untouched, the error is + * published to `getUpdateErrors$`, and the returned promise rejects. + */ + set(key: string, value: T): Promise; + + /** + * Removes the user override via `DELETE /internal/user_storage/{key}`. + * On success the cached value is deleted (subsequent reads fall back to + * `defaultValue`) and subscribers are notified. + */ + remove(key: string): Promise; + + /** + * Stream of every successful key update. + */ + getUpdate$(): Observable; + + /** + * Stream of HTTP errors raised by `set` / `remove` after the call returned + * to the caller. Suitable for centralised toast / telemetry handling. + */ + getUpdateErrors$(): Observable; +} diff --git a/src/core/packages/user-storage/browser/src/use_user_storage.test.tsx b/src/core/packages/user-storage/browser/src/use_user_storage.test.tsx new file mode 100644 index 0000000000000..10f5a6a63d3ce --- /dev/null +++ b/src/core/packages/user-storage/browser/src/use_user_storage.test.tsx @@ -0,0 +1,120 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React from 'react'; +import { act, renderHook } from '@testing-library/react'; +import { BehaviorSubject } from 'rxjs'; +import type { IUserStorageClient } from './types'; +import { UserStorageProvider } from './user_storage_provider'; +import { useUserStorage, useUserStorageClient } from './use_user_storage'; + +const buildClient = (initial: Record = {}): IUserStorageClient => { + const cache: Record = { ...initial }; + const subject$ = new BehaviorSubject>(cache); + const client: IUserStorageClient = { + get: ((key: string, defaultValue?: unknown) => + (cache[key] !== undefined ? cache[key] : defaultValue) as never) as IUserStorageClient['get'], + get$: ((key: string, defaultValue?: unknown) => { + // simple "get current value" observable + return new BehaviorSubject( + cache[key] !== undefined ? cache[key] : defaultValue + ).asObservable(); + }) as IUserStorageClient['get$'], + getAll: () => ({ ...cache }), + set: jest.fn(async (key: string, value: unknown) => { + cache[key] = value; + subject$.next({ ...cache }); + }) as IUserStorageClient['set'], + remove: jest.fn(async (key: string) => { + delete cache[key]; + subject$.next({ ...cache }); + }) as IUserStorageClient['remove'], + getUpdate$: () => new BehaviorSubject({ key: '', newValue: undefined, oldValue: undefined }), + getUpdateErrors$: () => new BehaviorSubject(new Error('noop')), + }; + return client; +}; + +const wrapper = + (client: IUserStorageClient) => + ({ children }: { children: React.ReactNode }) => + {children}; + +// React surfaces render-time errors via console.error; suppress to keep +// expected-throw tests from polluting test output. +const expectThrowsFromRender = (fn: () => unknown, pattern: RegExp) => { + const spy = jest.spyOn(console, 'error').mockImplementation(() => {}); + try { + expect(fn).toThrow(pattern); + } finally { + spy.mockRestore(); + } +}; + +describe('useUserStorageClient', () => { + it('throws a clear error when called outside ', () => { + expectThrowsFromRender( + () => renderHook(() => useUserStorageClient()), + /must be used inside a / + ); + }); + + it('returns the provided client', () => { + const client = buildClient(); + + const { result } = renderHook(() => useUserStorageClient(), { wrapper: wrapper(client) }); + + expect(result.current).toBe(client); + }); +}); + +describe('useUserStorage', () => { + it('throws when called outside ', () => { + expectThrowsFromRender( + () => renderHook(() => useUserStorage('any')), + /must be used inside a / + ); + }); + + it('returns the cached value as the initial render', () => { + const client = buildClient({ 'navigation:layout': { hidden: ['discover'] } }); + + const { result } = renderHook(() => useUserStorage<{ hidden: string[] }>('navigation:layout'), { + wrapper: wrapper(client), + }); + + const [value] = result.current; + expect(value).toEqual({ hidden: ['discover'] }); + }); + + it('falls back to defaultValue when the key is missing', () => { + const client = buildClient(); + + const { result } = renderHook(() => useUserStorage('missing', 'fallback'), { + wrapper: wrapper(client), + }); + + const [value] = result.current; + expect(value).toBe('fallback'); + }); + + it('calls client.set when the setter is invoked', async () => { + const client = buildClient(); + + const { result } = renderHook(() => useUserStorage('counter', 0), { + wrapper: wrapper(client), + }); + + await act(async () => { + await result.current[1](7); + }); + + expect(client.set).toHaveBeenCalledWith('counter', 7); + }); +}); diff --git a/src/core/packages/user-storage/browser/src/use_user_storage.ts b/src/core/packages/user-storage/browser/src/use_user_storage.ts new file mode 100644 index 0000000000000..ece4db14e36d6 --- /dev/null +++ b/src/core/packages/user-storage/browser/src/use_user_storage.ts @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { useCallback, useContext, useMemo } from 'react'; +import useObservable from 'react-use/lib/useObservable'; +import type { IUserStorageClient } from './types'; +import { UserStorageContext } from './user_storage_context'; + +const PROVIDER_MISSING_MESSAGE = + 'useUserStorage / useUserStorageClient must be used inside a . ' + + 'Wrap your component tree in .'; + +/** + * Returns the {@link IUserStorageClient} provided by the nearest + * {@link UserStorageProvider}. Throws if no provider is mounted in the tree. + * + * @public + */ +export const useUserStorageClient = (): IUserStorageClient => { + const client = useContext(UserStorageContext); + if (!client) { + throw new Error(PROVIDER_MISSING_MESSAGE); + } + return client; +}; + +export type UserStorageSetter = (newValue: T) => Promise; + +/** + * Subscribes to a single user-storage key and returns a `[value, setter]` + * tuple. The value reflects the synchronous cache and re-renders on change. + * The setter persists via HTTP and updates the cache on success. + * + * @example + * ```tsx + * const [layout, setLayout] = useUserStorage( + * 'navigation:layout', + * defaultLayout + * ); + * ``` + * + * @public + */ +export const useUserStorage = ( + key: string, + defaultValue?: T +): [T, UserStorageSetter] => { + const client = useUserStorageClient(); + + const observable$ = useMemo(() => client.get$(key, defaultValue), [client, key, defaultValue]); + const value = useObservable(observable$, client.get(key, defaultValue as T)); + const set = useCallback>( + (newValue) => client.set(key, newValue), + [client, key] + ); + + return [value as T, set]; +}; diff --git a/src/core/packages/user-storage/browser/src/user_storage_context.ts b/src/core/packages/user-storage/browser/src/user_storage_context.ts new file mode 100644 index 0000000000000..2a8a227bbb903 --- /dev/null +++ b/src/core/packages/user-storage/browser/src/user_storage_context.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { createContext } from 'react'; +import type { IUserStorageClient } from './types'; + +export const UserStorageContext = createContext(null); diff --git a/src/core/packages/user-storage/browser/src/user_storage_provider.tsx b/src/core/packages/user-storage/browser/src/user_storage_provider.tsx new file mode 100644 index 0000000000000..be256702facd1 --- /dev/null +++ b/src/core/packages/user-storage/browser/src/user_storage_provider.tsx @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React, { type ReactNode } from 'react'; +import type { IUserStorageClient } from './types'; +import { UserStorageContext } from './user_storage_context'; + +export interface UserStorageProviderProps { + client: IUserStorageClient; + children: ReactNode; +} + +/** + * Provider that exposes a {@link IUserStorageClient} to descendant components + * via React context. Required for `useUserStorage` and `useUserStorageClient`. + * + * @public + */ +export const UserStorageProvider = ({ client, children }: UserStorageProviderProps) => ( + {children} +); diff --git a/src/core/packages/user-storage/browser/tsconfig.json b/src/core/packages/user-storage/browser/tsconfig.json new file mode 100644 index 0000000000000..d2392d9df404a --- /dev/null +++ b/src/core/packages/user-storage/browser/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "@kbn/tsconfig-base/tsconfig.json", + "compilerOptions": { + "outDir": "target/types", + "types": [ + "jest", + "node" + ] + }, + "include": [ + "**/*.ts", + "**/*.tsx" + ], + "kbn_references": [ + "@kbn/core-user-storage-common" + ], + "exclude": [ + "target/**/*" + ] +} diff --git a/tsconfig.base.json b/tsconfig.base.json index 70e972324c431..283878c828029 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -882,6 +882,12 @@ "@kbn/core-user-settings-server-internal/*": ["src/core/packages/user-settings/server-internal/*"], "@kbn/core-user-settings-server-mocks": ["src/core/packages/user-settings/server-mocks"], "@kbn/core-user-settings-server-mocks/*": ["src/core/packages/user-settings/server-mocks/*"], + "@kbn/core-user-storage-browser": ["src/core/packages/user-storage/browser"], + "@kbn/core-user-storage-browser/*": ["src/core/packages/user-storage/browser/*"], + "@kbn/core-user-storage-browser-internal": ["src/core/packages/user-storage/browser-internal"], + "@kbn/core-user-storage-browser-internal/*": ["src/core/packages/user-storage/browser-internal/*"], + "@kbn/core-user-storage-browser-mocks": ["src/core/packages/user-storage/browser-mocks"], + "@kbn/core-user-storage-browser-mocks/*": ["src/core/packages/user-storage/browser-mocks/*"], "@kbn/core-user-storage-common": ["src/core/packages/user-storage/common"], "@kbn/core-user-storage-common/*": ["src/core/packages/user-storage/common/*"], "@kbn/core-user-storage-server": ["src/core/packages/user-storage/server"], diff --git a/yarn.lock b/yarn.lock index 5d1664769be6a..56df17e772a92 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6283,6 +6283,14 @@ version "0.0.0" uid "" +"@kbn/core-user-storage-browser-internal@link:src/core/packages/user-storage/browser-internal": + version "0.0.0" + uid "" + +"@kbn/core-user-storage-browser@link:src/core/packages/user-storage/browser": + version "0.0.0" + uid "" + "@kbn/core-user-storage-common@link:src/core/packages/user-storage/common": version "0.0.0" uid "" From d99db2401c85e7261294fa9ad6cfa6bacfbce564 Mon Sep 17 00:00:00 2001 From: Timothy Sullivan Date: Thu, 7 May 2026 13:38:20 -0700 Subject: [PATCH 02/41] rendering integration: add initial values to HTML to prime the client --- .../src/injected_metadata_service.test.ts | 14 +++ .../src/injected_metadata_service.ts | 4 + .../browser-internal/src/types.ts | 3 + .../src/injected_metadata_service.mock.ts | 1 + .../common-internal/src/types.ts | 9 ++ .../rendering/server-internal/moon.yml | 3 + .../rendering_service.test.ts.snap | 60 ++++++++++++ .../src/rendering_service.test.ts | 94 +++++++++++++++++++ .../server-internal/src/rendering_service.tsx | 54 ++++++++++- .../src/test_helpers/params.ts | 2 + .../rendering/server-internal/src/types.ts | 7 ++ .../rendering/server-internal/tsconfig.json | 3 + 12 files changed, 251 insertions(+), 3 deletions(-) diff --git a/src/core/packages/injected-metadata/browser-internal/src/injected_metadata_service.test.ts b/src/core/packages/injected-metadata/browser-internal/src/injected_metadata_service.test.ts index e933b806cd161..7ad33367b9239 100644 --- a/src/core/packages/injected-metadata/browser-internal/src/injected_metadata_service.test.ts +++ b/src/core/packages/injected-metadata/browser-internal/src/injected_metadata_service.test.ts @@ -195,3 +195,17 @@ describe('setup.getFeatureFlags()', () => { expect(contract.getFeatureFlags()).toBeUndefined(); }); }); + +describe('setup.getUserStorage()', () => { + it('returns injectedMetadata.userStorage', () => { + const injectedMetadata = new InjectedMetadataService({ + injectedMetadata: { + userStorage: { values: { 'navigation:layout': { hidden: ['discover'] } } }, + }, + } as unknown as InjectedMetadataParams); + + expect(injectedMetadata.setup().getUserStorage()).toEqual({ + values: { 'navigation:layout': { hidden: ['discover'] } }, + }); + }); +}); diff --git a/src/core/packages/injected-metadata/browser-internal/src/injected_metadata_service.ts b/src/core/packages/injected-metadata/browser-internal/src/injected_metadata_service.ts index b9594b9f042e3..22e8f911b1581 100644 --- a/src/core/packages/injected-metadata/browser-internal/src/injected_metadata_service.ts +++ b/src/core/packages/injected-metadata/browser-internal/src/injected_metadata_service.ts @@ -99,6 +99,10 @@ export class InjectedMetadataService { getFeatureFlags: () => { return this.state.featureFlags; }, + + getUserStorage: () => { + return this.state.userStorage; + }, }; } } diff --git a/src/core/packages/injected-metadata/browser-internal/src/types.ts b/src/core/packages/injected-metadata/browser-internal/src/types.ts index 07f26644b53e0..a3100377021a0 100644 --- a/src/core/packages/injected-metadata/browser-internal/src/types.ts +++ b/src/core/packages/injected-metadata/browser-internal/src/types.ts @@ -64,6 +64,9 @@ export interface InternalInjectedMetadataSetup { initialFeatureFlags: Record; } | undefined; + getUserStorage: () => { + values: Record; + }; } /** @internal */ diff --git a/src/core/packages/injected-metadata/browser-mocks/src/injected_metadata_service.mock.ts b/src/core/packages/injected-metadata/browser-mocks/src/injected_metadata_service.mock.ts index 032b3819ee7bd..e9dd9698cf8da 100644 --- a/src/core/packages/injected-metadata/browser-mocks/src/injected_metadata_service.mock.ts +++ b/src/core/packages/injected-metadata/browser-mocks/src/injected_metadata_service.mock.ts @@ -54,6 +54,7 @@ const createSetupContractMock = () => { getKibanaBuildNumber: jest.fn(), getCustomBranding: jest.fn(), getFeatureFlags: jest.fn(), + getUserStorage: jest.fn().mockReturnValue({ values: {} }), }); return setupContract; diff --git a/src/core/packages/injected-metadata/common-internal/src/types.ts b/src/core/packages/injected-metadata/common-internal/src/types.ts index fad41395a921c..62c7b08d011fa 100644 --- a/src/core/packages/injected-metadata/common-internal/src/types.ts +++ b/src/core/packages/injected-metadata/common-internal/src/types.ts @@ -93,4 +93,13 @@ export interface InjectedMetadata { }; }; customBranding: Pick; + /** + * Per-user values pre-fetched on the server for keys registered with the + * user storage service. Empty on anonymous pages or when the request has + * no `profile_uid`. Browser code should treat the values as a snapshot at + * page load and fall back to registered defaults for missing keys. + */ + userStorage: { + values: Record; + }; } diff --git a/src/core/packages/rendering/server-internal/moon.yml b/src/core/packages/rendering/server-internal/moon.yml index 8468c56a014ad..06f3f8fc07599 100644 --- a/src/core/packages/rendering/server-internal/moon.yml +++ b/src/core/packages/rendering/server-internal/moon.yml @@ -52,6 +52,9 @@ dependsOn: - '@kbn/core-feature-flags-server-internal' - '@kbn/core-feature-flags-server-mocks' - '@kbn/core-feature-flags-server' + - '@kbn/core-user-storage-server' + - '@kbn/core-user-storage-server-mocks' + - '@kbn/logging' - '@kbn/repo-info' tags: - shared-server diff --git a/src/core/packages/rendering/server-internal/src/__snapshots__/rendering_service.test.ts.snap b/src/core/packages/rendering/server-internal/src/__snapshots__/rendering_service.test.ts.snap index 3937764b33eb9..e366ea112c3ab 100644 --- a/src/core/packages/rendering/server-internal/src/__snapshots__/rendering_service.test.ts.snap +++ b/src/core/packages/rendering/server-internal/src/__snapshots__/rendering_service.test.ts.snap @@ -91,6 +91,9 @@ Object { "version": "v8", }, "uiPlugins": Array [], + "userStorage": Object { + "values": Object {}, + }, "version": Any, } `; @@ -182,6 +185,9 @@ Object { "version": "v8", }, "uiPlugins": Array [], + "userStorage": Object { + "values": Object {}, + }, "version": Any, } `; @@ -277,6 +283,9 @@ Object { "version": "v8", }, "uiPlugins": Array [], + "userStorage": Object { + "values": Object {}, + }, "version": Any, } `; @@ -368,6 +377,9 @@ Object { "version": "v8", }, "uiPlugins": Array [], + "userStorage": Object { + "values": Object {}, + }, "version": Any, } `; @@ -459,6 +471,9 @@ Object { "version": "v8", }, "uiPlugins": Array [], + "userStorage": Object { + "values": Object {}, + }, "version": Any, } `; @@ -554,6 +569,9 @@ Object { "version": "v8", }, "uiPlugins": Array [], + "userStorage": Object { + "values": Object {}, + }, "version": Any, } `; @@ -645,6 +663,9 @@ Object { "version": "v8", }, "uiPlugins": Array [], + "userStorage": Object { + "values": Object {}, + }, "version": Any, } `; @@ -736,6 +757,9 @@ Object { "version": "v8", }, "uiPlugins": Array [], + "userStorage": Object { + "values": Object {}, + }, "version": Any, } `; @@ -827,6 +851,9 @@ Object { "version": "v8", }, "uiPlugins": Array [], + "userStorage": Object { + "values": Object {}, + }, "version": Any, } `; @@ -918,6 +945,9 @@ Object { "version": "v8", }, "uiPlugins": Array [], + "userStorage": Object { + "values": Object {}, + }, "version": Any, } `; @@ -1018,6 +1048,9 @@ Object { "version": "v8", }, "uiPlugins": Array [], + "userStorage": Object { + "values": Object {}, + }, "version": Any, } `; @@ -1111,6 +1144,9 @@ Object { "version": "v8", }, "uiPlugins": Array [], + "userStorage": Object { + "values": Object {}, + }, "version": Any, } `; @@ -1211,6 +1247,9 @@ Object { "version": "v8", }, "uiPlugins": Array [], + "userStorage": Object { + "values": Object {}, + }, "version": Any, } `; @@ -1307,6 +1346,9 @@ Object { "version": "v8", }, "uiPlugins": Array [], + "userStorage": Object { + "values": Object {}, + }, "version": Any, } `; @@ -1398,6 +1440,9 @@ Object { "version": "v8", }, "uiPlugins": Array [], + "userStorage": Object { + "values": Object {}, + }, "version": Any, } `; @@ -1498,6 +1543,9 @@ Object { "version": "v8", }, "uiPlugins": Array [], + "userStorage": Object { + "values": Object {}, + }, "version": Any, } `; @@ -1594,6 +1642,9 @@ Object { "version": "v8", }, "uiPlugins": Array [], + "userStorage": Object { + "values": Object {}, + }, "version": Any, } `; @@ -1690,6 +1741,9 @@ Object { "version": "v8", }, "uiPlugins": Array [], + "userStorage": Object { + "values": Object {}, + }, "version": Any, } `; @@ -1788,6 +1842,9 @@ Object { "version": "v8", }, "uiPlugins": Array [], + "userStorage": Object { + "values": Object {}, + }, "version": Any, } `; @@ -1886,6 +1943,9 @@ Object { "version": "v8", }, "uiPlugins": Array [], + "userStorage": Object { + "values": Object {}, + }, "version": Any, } `; diff --git a/src/core/packages/rendering/server-internal/src/rendering_service.test.ts b/src/core/packages/rendering/server-internal/src/rendering_service.test.ts index 17f8a1d07b503..7d0c4d183f742 100644 --- a/src/core/packages/rendering/server-internal/src/rendering_service.test.ts +++ b/src/core/packages/rendering/server-internal/src/rendering_service.test.ts @@ -735,6 +735,100 @@ describe('RenderingService', () => { }); }); + describe('userStorage injection', () => { + const renderAndReadUserStorage = async (content: string) => { + const dom = load(content); + const data = JSON.parse(dom('kbn-injected-metadata').attr('data') ?? '""'); + return data.userStorage; + }; + + const buildUiSettings = () => ({ + client: uiSettingsServiceMock.createClient(), + globalClient: uiSettingsServiceMock.createClient(), + }); + + it('injects values returned by userStorage.asScoped().getAll()', async () => { + const { render } = await service.setup(mockRenderingSetupDeps); + + const getAll = jest.fn().mockResolvedValue({ 'navigation:layout': { hidden: ['discover'] } }); + const asScoped = jest.fn().mockReturnValue({ getAll }); + service.start({ ...mockRenderingStartDeps, userStorage: { asScoped } }); + + const content = await render(createKibanaRequest(), buildUiSettings()); + + expect(asScoped).toHaveBeenCalledTimes(1); + expect(getAll).toHaveBeenCalledTimes(1); + expect(await renderAndReadUserStorage(content)).toEqual({ + values: { 'navigation:layout': { hidden: ['discover'] } }, + }); + }); + + it('injects empty values when start() was never called (no userStorageStart)', async () => { + const { render } = await service.setup(mockRenderingSetupDeps); + + const content = await render(createKibanaRequest(), buildUiSettings()); + + expect(await renderAndReadUserStorage(content)).toEqual({ values: {} }); + }); + + it('injects empty values when asScoped() returns null (no profile_uid)', async () => { + const { render } = await service.setup(mockRenderingSetupDeps); + + const asScoped = jest.fn().mockReturnValue(null); + service.start({ ...mockRenderingStartDeps, userStorage: { asScoped } }); + + const content = await render(createKibanaRequest(), buildUiSettings()); + + expect(asScoped).toHaveBeenCalledTimes(1); + expect(await renderAndReadUserStorage(content)).toEqual({ values: {} }); + }); + + it('injects empty values for anonymous pages without consulting userStorage', async () => { + const { render } = await service.setup(mockRenderingSetupDeps); + + const asScoped = jest.fn(); + service.start({ ...mockRenderingStartDeps, userStorage: { asScoped } }); + + const content = await render(createKibanaRequest(), buildUiSettings(), { + isAnonymousPage: true, + }); + + expect(asScoped).not.toHaveBeenCalled(); + expect(await renderAndReadUserStorage(content)).toEqual({ values: {} }); + }); + + it('falls back to empty values when getAll() rejects', async () => { + const { render } = await service.setup(mockRenderingSetupDeps); + + const getAll = jest.fn().mockRejectedValue(new Error('ES exploded')); + const asScoped = jest.fn().mockReturnValue({ getAll }); + service.start({ ...mockRenderingStartDeps, userStorage: { asScoped } }); + + const content = await render(createKibanaRequest(), buildUiSettings()); + + expect(await renderAndReadUserStorage(content)).toEqual({ values: {} }); + }); + + it('falls back to empty values when getAll() exceeds the timeout', async () => { + const { render } = await service.setup(mockRenderingSetupDeps); + + // resolves after the 50ms render-time budget; the rendering path + // should not wait for it. + const getAll = jest + .fn() + .mockImplementation(() => new Promise((resolve) => setTimeout(() => resolve({}), 5_000))); + const asScoped = jest.fn().mockReturnValue({ getAll }); + service.start({ ...mockRenderingStartDeps, userStorage: { asScoped } }); + + const renderPromise = render(createKibanaRequest(), buildUiSettings()); + // advance past the 50ms timeout so RxJS' `timeout` operator fires. + await jest.advanceTimersByTimeAsync(60); + const content = await renderPromise; + + expect(await renderAndReadUserStorage(content)).toEqual({ values: {} }); + }); + }); + describe('rspack mode metadata', () => { let rspackService: RenderingService; diff --git a/src/core/packages/rendering/server-internal/src/rendering_service.tsx b/src/core/packages/rendering/server-internal/src/rendering_service.tsx index 35d092b5f0d95..96f07a05296dd 100644 --- a/src/core/packages/rendering/server-internal/src/rendering_service.tsx +++ b/src/core/packages/rendering/server-internal/src/rendering_service.tsx @@ -9,15 +9,17 @@ import React from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; -import { BehaviorSubject, firstValueFrom, of, map, catchError, take, timeout } from 'rxjs'; +import { BehaviorSubject, defer, firstValueFrom, of, map, catchError, take, timeout } from 'rxjs'; import { i18n as i18nLib } from '@kbn/i18n'; import type { ThemeVersion } from '@kbn/ui-shared-deps-npm'; +import type { Logger } from '@kbn/logging'; import type { CoreContext } from '@kbn/core-base-server-internal'; import type { KibanaRequest, HttpAuth } from '@kbn/core-http-server'; import type { IUiSettingsClient } from '@kbn/core-ui-settings-server'; import type { UiPlugins } from '@kbn/core-plugins-base-server-internal'; import type { CustomBranding } from '@kbn/core-custom-branding-common'; +import type { UserStorageServiceStart } from '@kbn/core-user-storage-server'; import { type DarkModeValue, type ThemeName, @@ -71,7 +73,14 @@ export class RenderingService { private readonly themeName$ = new BehaviorSubject(DEFAULT_THEME_NAME); private airgapped: boolean = false; private isCoreRenderingInReactConcurrentMode: boolean = true; - constructor(private readonly coreContext: CoreContext) {} + private readonly logger: Logger; + // Set in `start()`; render() may fire pre-start in theory (e.g. status page + // during boot), so the field is treated as possibly-undefined and the + // metadata falls back to empty values in that case. + private userStorageStart?: UserStorageServiceStart; + constructor(private readonly coreContext: CoreContext) { + this.logger = coreContext.logger.get('rendering'); + } public async preboot({ http, @@ -140,7 +149,8 @@ export class RenderingService { }; } - public start({ featureFlags }: RenderingStartDeps) { + public start({ featureFlags, userStorage }: RenderingStartDeps) { + this.userStorageStart = userStorage; featureFlags .getStringValue$(DEFAULT_THEME_NAME_FEATURE_FLAG, DEFAULT_THEME_NAME) // Parse the input feature flag value to ensure it's of type ThemeName @@ -188,6 +198,17 @@ export class RenderingService { const basePath = http.basePath.get(request); const { serverBasePath, publicBaseUrl } = http.basePath; + // Kick off the user-storage fetch concurrently with the settings reads + // below. The browser uses these values to seed its local cache so the + // first paint reflects the user's customizations (e.g. side-nav order) + // without a flash of defaults. Skipped for anonymous pages because user + // storage requires a profile_uid, and bounded by a 50ms timeout so a + // slow ES read never blocks first paint — see clusterInfo below for + // the same pattern. + const userStorageValuesPromise: Promise> = isAnonymousPage + ? Promise.resolve({}) + : this.fetchUserStorageValues(request); + // Grouping all async HTTP requests to run them concurrently for performance reasons. const [ defaultSettings, @@ -294,6 +315,8 @@ export class RenderingService { const filteredPlugins = filterUiPlugins({ uiPlugins, isAnonymousPage }); const bootstrapScript = isAnonymousPage ? 'bootstrap-anonymous.js' : 'bootstrap.js'; + const userStorageValues = await userStorageValuesPromise; + const useRspack = isRspackModeEnabled(); const uiPublicUrl = `${staticAssetsHrefBase}/ui`; @@ -384,6 +407,7 @@ export class RenderingService { uiSettings: settings, globalUiSettings: globalSettings, }, + userStorage: { values: userStorageValues }, }, }; @@ -391,6 +415,30 @@ export class RenderingService { } public async stop() {} + + private async fetchUserStorageValues(request: KibanaRequest): Promise> { + const userStorage = this.userStorageStart; + if (!userStorage) return {}; + + const client = userStorage.asScoped(request); + if (!client) return {}; + + return firstValueFrom( + defer(() => client.getAll()).pipe( + timeout(50), + catchError((err) => { + // Expected for first-login (no SO yet) and for ES-slow scenarios. + // Log at debug to keep first-login from spamming warn-level logs. + this.logger.debug( + `Falling back to default userStorage values for render: ${ + err instanceof Error ? err.message : String(err) + }` + ); + return of>({}); + }) + ) + ); + } } const getUiConfig = async (uiPlugins: UiPlugins, pluginId: string) => { diff --git a/src/core/packages/rendering/server-internal/src/test_helpers/params.ts b/src/core/packages/rendering/server-internal/src/test_helpers/params.ts index 05483393c0c36..15942e23ed222 100644 --- a/src/core/packages/rendering/server-internal/src/test_helpers/params.ts +++ b/src/core/packages/rendering/server-internal/src/test_helpers/params.ts @@ -16,6 +16,7 @@ import { customBrandingServiceMock } from '@kbn/core-custom-branding-server-mock import { userSettingsServiceMock } from '@kbn/core-user-settings-server-mocks'; import { i18nServiceMock } from '@kbn/core-i18n-server-mocks'; import { coreFeatureFlagsMock } from '@kbn/core-feature-flags-server-mocks'; +import { userStorageServiceMock } from '@kbn/core-user-storage-server-mocks'; const context = mockCoreContext.create(); // Mock the airgapped config to return a boolean value @@ -53,4 +54,5 @@ export const mockRenderingSetupDeps = { }; export const mockRenderingStartDeps = { featureFlags: coreFeatureFlagsMock.createStart(), + userStorage: userStorageServiceMock.createStartContract(), }; diff --git a/src/core/packages/rendering/server-internal/src/types.ts b/src/core/packages/rendering/server-internal/src/types.ts index cdaeff3abe979..878559dc19db4 100644 --- a/src/core/packages/rendering/server-internal/src/types.ts +++ b/src/core/packages/rendering/server-internal/src/types.ts @@ -26,6 +26,7 @@ import type { I18nServiceSetup } from '@kbn/core-i18n-server'; import type { InternalI18nServicePreboot } from '@kbn/core-i18n-server-internal'; import type { InternalFeatureFlagsSetup } from '@kbn/core-feature-flags-server-internal'; import type { FeatureFlagsStart } from '@kbn/core-feature-flags-server'; +import type { UserStorageServiceStart } from '@kbn/core-user-storage-server'; /** @internal */ export interface RenderingMetadata { @@ -68,6 +69,12 @@ export interface RenderingSetupDeps { /** @internal */ export interface RenderingStartDeps { featureFlags: FeatureFlagsStart; + /** + * Optional because `render()` defensively handles `userStorage` being + * absent (renders empty `userStorage.values` into the metadata, browser + * falls back to defaults). In practice `start()` always passes it. + */ + userStorage?: UserStorageServiceStart; } /** @internal */ diff --git a/src/core/packages/rendering/server-internal/tsconfig.json b/src/core/packages/rendering/server-internal/tsconfig.json index 169ab59a880b6..cdeb9efa41dfb 100644 --- a/src/core/packages/rendering/server-internal/tsconfig.json +++ b/src/core/packages/rendering/server-internal/tsconfig.json @@ -48,6 +48,9 @@ "@kbn/core-feature-flags-server-internal", "@kbn/core-feature-flags-server-mocks", "@kbn/core-feature-flags-server", + "@kbn/core-user-storage-server", + "@kbn/core-user-storage-server-mocks", + "@kbn/logging", "@kbn/repo-info", ], "exclude": [ From 3705dfdf7875c39a0c9487f4eac3af7f349f512d Mon Sep 17 00:00:00 2001 From: Timothy Sullivan Date: Thu, 7 May 2026 13:38:44 -0700 Subject: [PATCH 03/41] optimize objectsToFetch in server client --- .../src/user_storage_client.ts | 37 +++++++++++-------- 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/src/core/packages/user-storage/server-internal/src/user_storage_client.ts b/src/core/packages/user-storage/server-internal/src/user_storage_client.ts index 51d130a8bd110..cd8f6a3949615 100644 --- a/src/core/packages/user-storage/server-internal/src/user_storage_client.ts +++ b/src/core/packages/user-storage/server-internal/src/user_storage_client.ts @@ -73,10 +73,27 @@ export class UserStorageClient implements IUserStorageClient { if (hasSpace && hasGlobal) break; } - const [spaceData, globalData] = await Promise.all([ - hasSpace ? this.readSoData(USER_STORAGE_SO_TYPE) : undefined, - hasGlobal ? this.readSoData(USER_STORAGE_GLOBAL_SO_TYPE) : undefined, - ]); + const objectsToFetch: Array<{ type: string; id: string }> = []; + if (hasSpace) objectsToFetch.push({ type: USER_STORAGE_SO_TYPE, id: this.profileUid }); + if (hasGlobal) objectsToFetch.push({ type: USER_STORAGE_GLOBAL_SO_TYPE, id: this.profileUid }); + + let spaceData: Record | undefined; + let globalData: Record | undefined; + + if (objectsToFetch.length > 0) { + const { saved_objects: docs } = await this.soClient.bulkGet( + objectsToFetch + ); + for (const doc of docs) { + // bulkGet surfaces a missing SO via `doc.error` rather than throwing. + if (doc.error) continue; + if (doc.type === USER_STORAGE_SO_TYPE) { + spaceData = doc.attributes.data; + } else if (doc.type === USER_STORAGE_GLOBAL_SO_TYPE) { + globalData = doc.attributes.data; + } + } + } const result: Record = {}; for (const [key, definition] of this.definitions) { @@ -157,16 +174,4 @@ export class UserStorageClient implements IUserStorageClient { private getSoType(definition: UserStorageDefinition): string { return definition.scope === 'space' ? USER_STORAGE_SO_TYPE : USER_STORAGE_GLOBAL_SO_TYPE; } - - private async readSoData(soType: string): Promise | undefined> { - try { - const doc = await this.soClient.get(soType, this.profileUid); - return doc.attributes.data; - } catch (err) { - if (SavedObjectsErrorHelpers.isNotFoundError(err)) { - return undefined; - } - throw err; - } - } } From b2fb0bdc9c95472a7b68fbc24ad3f322cdefbc14 Mon Sep 17 00:00:00 2001 From: Timothy Sullivan Date: Thu, 7 May 2026 13:38:58 -0700 Subject: [PATCH 04/41] comment simplification --- .../server-internal/src/rendering_service.tsx | 16 ++++------------ .../rendering/server-internal/src/types.ts | 6 +----- .../browser-internal/src/user_storage_api.ts | 3 +-- .../browser-internal/src/user_storage_client.ts | 6 ++---- 4 files changed, 8 insertions(+), 23 deletions(-) diff --git a/src/core/packages/rendering/server-internal/src/rendering_service.tsx b/src/core/packages/rendering/server-internal/src/rendering_service.tsx index 96f07a05296dd..53eff8ff2e00f 100644 --- a/src/core/packages/rendering/server-internal/src/rendering_service.tsx +++ b/src/core/packages/rendering/server-internal/src/rendering_service.tsx @@ -74,9 +74,7 @@ export class RenderingService { private airgapped: boolean = false; private isCoreRenderingInReactConcurrentMode: boolean = true; private readonly logger: Logger; - // Set in `start()`; render() may fire pre-start in theory (e.g. status page - // during boot), so the field is treated as possibly-undefined and the - // metadata falls back to empty values in that case. + // Optional so `render()` is safe to call before `start()` runs. private userStorageStart?: UserStorageServiceStart; constructor(private readonly coreContext: CoreContext) { this.logger = coreContext.logger.get('rendering'); @@ -198,13 +196,8 @@ export class RenderingService { const basePath = http.basePath.get(request); const { serverBasePath, publicBaseUrl } = http.basePath; - // Kick off the user-storage fetch concurrently with the settings reads - // below. The browser uses these values to seed its local cache so the - // first paint reflects the user's customizations (e.g. side-nav order) - // without a flash of defaults. Skipped for anonymous pages because user - // storage requires a profile_uid, and bounded by a 50ms timeout so a - // slow ES read never blocks first paint — see clusterInfo below for - // the same pattern. + // 50ms budget inside `fetchUserStorageValues` so a slow ES read never + // blocks first paint. Anonymous pages have no profile_uid, so skip. const userStorageValuesPromise: Promise> = isAnonymousPage ? Promise.resolve({}) : this.fetchUserStorageValues(request); @@ -427,8 +420,7 @@ export class RenderingService { defer(() => client.getAll()).pipe( timeout(50), catchError((err) => { - // Expected for first-login (no SO yet) and for ES-slow scenarios. - // Log at debug to keep first-login from spamming warn-level logs. + // debug, not warn: first-login (no SO yet) is the common case. this.logger.debug( `Falling back to default userStorage values for render: ${ err instanceof Error ? err.message : String(err) diff --git a/src/core/packages/rendering/server-internal/src/types.ts b/src/core/packages/rendering/server-internal/src/types.ts index 878559dc19db4..e9c89cd0f853a 100644 --- a/src/core/packages/rendering/server-internal/src/types.ts +++ b/src/core/packages/rendering/server-internal/src/types.ts @@ -69,11 +69,7 @@ export interface RenderingSetupDeps { /** @internal */ export interface RenderingStartDeps { featureFlags: FeatureFlagsStart; - /** - * Optional because `render()` defensively handles `userStorage` being - * absent (renders empty `userStorage.values` into the metadata, browser - * falls back to defaults). In practice `start()` always passes it. - */ + /** Optional so `render()` is safe to call before `start()` runs. */ userStorage?: UserStorageServiceStart; } diff --git a/src/core/packages/user-storage/browser-internal/src/user_storage_api.ts b/src/core/packages/user-storage/browser-internal/src/user_storage_api.ts index 636ebbcae9fa3..e971b9b8589ac 100644 --- a/src/core/packages/user-storage/browser-internal/src/user_storage_api.ts +++ b/src/core/packages/user-storage/browser-internal/src/user_storage_api.ts @@ -13,8 +13,7 @@ const BASE_PATH = '/internal/user_storage'; /** * Thin HTTP wrapper over the user-storage internal routes. Each method maps - * to a single round-trip; callers (the {@link UserStorageClient}) own caching - * and observable state. + * to one HTTP round-trip; no caching. * * @internal */ diff --git a/src/core/packages/user-storage/browser-internal/src/user_storage_client.ts b/src/core/packages/user-storage/browser-internal/src/user_storage_client.ts index c898214aa0709..db5d0bbf9ba38 100644 --- a/src/core/packages/user-storage/browser-internal/src/user_storage_client.ts +++ b/src/core/packages/user-storage/browser-internal/src/user_storage_client.ts @@ -21,10 +21,8 @@ export interface UserStorageClientParams { } /** - * Browser-side {@link IUserStorageClient}. Holds a synchronous in-memory - * cache seeded from server-injected metadata. Mirrors the read/observable - * surface of `UiSettingsClientCommon`, simplified for user-storage's coarser - * write semantics (no batching, no validation roundtrip). + * Browser-side {@link IUserStorageClient}: a synchronous in-memory cache + * seeded from server-injected metadata, with HTTP-backed writes. * * @internal */ From 052c48759e81e634570e9667e865dbcbcef95eca Mon Sep 17 00:00:00 2001 From: Timothy Sullivan Date: Thu, 7 May 2026 15:13:00 -0700 Subject: [PATCH 05/41] type check fix --- .../packages/plugins/browser-internal/src/plugin_context.ts | 2 ++ .../ml/public/application/datavisualizer/file_based/util.ts | 1 + 2 files changed, 3 insertions(+) diff --git a/src/core/packages/plugins/browser-internal/src/plugin_context.ts b/src/core/packages/plugins/browser-internal/src/plugin_context.ts index 8d849b312ae44..9886910e57ba8 100644 --- a/src/core/packages/plugins/browser-internal/src/plugin_context.ts +++ b/src/core/packages/plugins/browser-internal/src/plugin_context.ts @@ -106,6 +106,7 @@ export function createPluginSetupContext< registerUserProfileDelegate: (delegate) => deps.userProfile.registerUserProfileDelegate(delegate), }, + userStorage: deps.userStorage, plugins: { onSetup: (...dependencyNames) => runtimeResolver.onSetup(plugin.name, dependencyNames), onStart: (...dependencyNames) => runtimeResolver.onStart(plugin.name, dependencyNames), @@ -178,6 +179,7 @@ export function createPluginStartContext< authc: deps.security.authc, }, userProfile: deps.userProfile, + userStorage: deps.userStorage, plugins: { onStart: (...dependencyNames) => runtimeResolver.onStart(plugin.name, dependencyNames), }, diff --git a/x-pack/platform/plugins/shared/ml/public/application/datavisualizer/file_based/util.ts b/x-pack/platform/plugins/shared/ml/public/application/datavisualizer/file_based/util.ts index baa6bb77baf73..6dccb03cab59b 100644 --- a/x-pack/platform/plugins/shared/ml/public/application/datavisualizer/file_based/util.ts +++ b/x-pack/platform/plugins/shared/ml/public/application/datavisualizer/file_based/util.ts @@ -44,6 +44,7 @@ export function buildDependencies(services: StartServices): FileUploadStartDepen pricing: services.pricing, security: services.security, userProfile: services.userProfile, + userStorage: services.userStorage, rendering: services.rendering, }, }; From ed7ddd38467ebe3bf6a7947a8db4435e6ff638d6 Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Thu, 7 May 2026 22:18:47 +0000 Subject: [PATCH 06/41] Changes from node scripts/lint.js --fix --- package.json | 1 + yarn.lock | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/package.json b/package.json index 7a73fb379efaa..aabfad4672d5f 100644 --- a/package.json +++ b/package.json @@ -1709,6 +1709,7 @@ "@kbn/core-ui-settings-server-mocks": "link:src/core/packages/ui-settings/server-mocks", "@kbn/core-usage-data-server-mocks": "link:src/core/packages/usage-data/server-mocks", "@kbn/core-user-activity-server-mocks": "link:src/core/packages/user-activity/server-mocks", + "@kbn/core-user-storage-browser-mocks": "link:src/core/packages/user-storage/browser-mocks", "@kbn/cypress-config": "link:src/platform/packages/shared/kbn-cypress-config", "@kbn/cypress-test-helper": "link:src/platform/packages/shared/kbn-cypress-test-helper", "@kbn/dependency-ownership": "link:packages/kbn-dependency-ownership", diff --git a/yarn.lock b/yarn.lock index 56df17e772a92..4d1e8c734838f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6287,6 +6287,10 @@ version "0.0.0" uid "" +"@kbn/core-user-storage-browser-mocks@link:src/core/packages/user-storage/browser-mocks": + version "0.0.0" + uid "" + "@kbn/core-user-storage-browser@link:src/core/packages/user-storage/browser": version "0.0.0" uid "" From 7c491258f1da368d564d899e2381998b2e60a598 Mon Sep 17 00:00:00 2001 From: Timothy Sullivan Date: Thu, 7 May 2026 15:22:03 -0700 Subject: [PATCH 07/41] update CODEOWNERS --- .github/CODEOWNERS | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index f1c94bfa2114a..16300b72b3450 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -349,6 +349,9 @@ src/core/packages/user-profile/server-mocks @elastic/kibana-core src/core/packages/user-settings/server @elastic/kibana-security src/core/packages/user-settings/server-internal @elastic/kibana-security src/core/packages/user-settings/server-mocks @elastic/kibana-security +src/core/packages/user-storage/browser @elastic/appex-sharedux +src/core/packages/user-storage/browser-internal @elastic/appex-sharedux +src/core/packages/user-storage/browser-mocks @elastic/appex-sharedux src/core/packages/user-storage/common @elastic/appex-sharedux src/core/packages/user-storage/server @elastic/appex-sharedux src/core/packages/user-storage/server-internal @elastic/appex-sharedux From 1308d1c11f7e865fed00657c8633f67a4b51d8a7 Mon Sep 17 00:00:00 2001 From: Timothy Sullivan Date: Thu, 7 May 2026 15:23:36 -0700 Subject: [PATCH 08/41] roll back unrelated optimization --- .../src/user_storage_client.ts | 37 ++++++++----------- 1 file changed, 16 insertions(+), 21 deletions(-) diff --git a/src/core/packages/user-storage/server-internal/src/user_storage_client.ts b/src/core/packages/user-storage/server-internal/src/user_storage_client.ts index cd8f6a3949615..51d130a8bd110 100644 --- a/src/core/packages/user-storage/server-internal/src/user_storage_client.ts +++ b/src/core/packages/user-storage/server-internal/src/user_storage_client.ts @@ -73,27 +73,10 @@ export class UserStorageClient implements IUserStorageClient { if (hasSpace && hasGlobal) break; } - const objectsToFetch: Array<{ type: string; id: string }> = []; - if (hasSpace) objectsToFetch.push({ type: USER_STORAGE_SO_TYPE, id: this.profileUid }); - if (hasGlobal) objectsToFetch.push({ type: USER_STORAGE_GLOBAL_SO_TYPE, id: this.profileUid }); - - let spaceData: Record | undefined; - let globalData: Record | undefined; - - if (objectsToFetch.length > 0) { - const { saved_objects: docs } = await this.soClient.bulkGet( - objectsToFetch - ); - for (const doc of docs) { - // bulkGet surfaces a missing SO via `doc.error` rather than throwing. - if (doc.error) continue; - if (doc.type === USER_STORAGE_SO_TYPE) { - spaceData = doc.attributes.data; - } else if (doc.type === USER_STORAGE_GLOBAL_SO_TYPE) { - globalData = doc.attributes.data; - } - } - } + const [spaceData, globalData] = await Promise.all([ + hasSpace ? this.readSoData(USER_STORAGE_SO_TYPE) : undefined, + hasGlobal ? this.readSoData(USER_STORAGE_GLOBAL_SO_TYPE) : undefined, + ]); const result: Record = {}; for (const [key, definition] of this.definitions) { @@ -174,4 +157,16 @@ export class UserStorageClient implements IUserStorageClient { private getSoType(definition: UserStorageDefinition): string { return definition.scope === 'space' ? USER_STORAGE_SO_TYPE : USER_STORAGE_GLOBAL_SO_TYPE; } + + private async readSoData(soType: string): Promise | undefined> { + try { + const doc = await this.soClient.get(soType, this.profileUid); + return doc.attributes.data; + } catch (err) { + if (SavedObjectsErrorHelpers.isNotFoundError(err)) { + return undefined; + } + throw err; + } + } } From de77277e525399c81e112fd721595a5718ea1cf9 Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Thu, 7 May 2026 23:15:28 +0000 Subject: [PATCH 09/41] Changes from node scripts/lint_ts_projects --fix --- src/core/packages/user-storage/browser/tsconfig.json | 1 - 1 file changed, 1 deletion(-) diff --git a/src/core/packages/user-storage/browser/tsconfig.json b/src/core/packages/user-storage/browser/tsconfig.json index d2392d9df404a..b794c1ced41c9 100644 --- a/src/core/packages/user-storage/browser/tsconfig.json +++ b/src/core/packages/user-storage/browser/tsconfig.json @@ -12,7 +12,6 @@ "**/*.tsx" ], "kbn_references": [ - "@kbn/core-user-storage-common" ], "exclude": [ "target/**/*" From 22e8d13c53808b88c0a2136dbec393cd329bf83d Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Thu, 7 May 2026 23:21:00 +0000 Subject: [PATCH 10/41] Changes from node scripts/regenerate_moon_projects.js --update --- src/core/packages/user-storage/browser/moon.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/core/packages/user-storage/browser/moon.yml b/src/core/packages/user-storage/browser/moon.yml index ba91604ad6b77..e41b65294266e 100644 --- a/src/core/packages/user-storage/browser/moon.yml +++ b/src/core/packages/user-storage/browser/moon.yml @@ -16,8 +16,7 @@ project: channel: '' owner: '@elastic/appex-sharedux' sourceRoot: src/core/packages/user-storage/browser -dependsOn: - - '@kbn/core-user-storage-common' +dependsOn: [] tags: - shared-browser - package From 463d71c838cf226b2309a5728e785ac8b792f31f Mon Sep 17 00:00:00 2001 From: Timothy Sullivan Date: Thu, 7 May 2026 19:35:30 -0700 Subject: [PATCH 11/41] rename provider prop --- .../user-storage/browser/src/use_user_storage.test.tsx | 2 +- .../packages/user-storage/browser/src/use_user_storage.ts | 2 +- .../user-storage/browser/src/user_storage_provider.tsx | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/core/packages/user-storage/browser/src/use_user_storage.test.tsx b/src/core/packages/user-storage/browser/src/use_user_storage.test.tsx index 10f5a6a63d3ce..4893caab9773c 100644 --- a/src/core/packages/user-storage/browser/src/use_user_storage.test.tsx +++ b/src/core/packages/user-storage/browser/src/use_user_storage.test.tsx @@ -44,7 +44,7 @@ const buildClient = (initial: Record = {}): IUserStorageClient const wrapper = (client: IUserStorageClient) => ({ children }: { children: React.ReactNode }) => - {children}; + {children}; // React surfaces render-time errors via console.error; suppress to keep // expected-throw tests from polluting test output. diff --git a/src/core/packages/user-storage/browser/src/use_user_storage.ts b/src/core/packages/user-storage/browser/src/use_user_storage.ts index ece4db14e36d6..0ea1d06e04bbb 100644 --- a/src/core/packages/user-storage/browser/src/use_user_storage.ts +++ b/src/core/packages/user-storage/browser/src/use_user_storage.ts @@ -14,7 +14,7 @@ import { UserStorageContext } from './user_storage_context'; const PROVIDER_MISSING_MESSAGE = 'useUserStorage / useUserStorageClient must be used inside a . ' + - 'Wrap your component tree in .'; + 'Wrap your component tree in .'; /** * Returns the {@link IUserStorageClient} provided by the nearest diff --git a/src/core/packages/user-storage/browser/src/user_storage_provider.tsx b/src/core/packages/user-storage/browser/src/user_storage_provider.tsx index be256702facd1..492362b21e0f4 100644 --- a/src/core/packages/user-storage/browser/src/user_storage_provider.tsx +++ b/src/core/packages/user-storage/browser/src/user_storage_provider.tsx @@ -12,7 +12,7 @@ import type { IUserStorageClient } from './types'; import { UserStorageContext } from './user_storage_context'; export interface UserStorageProviderProps { - client: IUserStorageClient; + userStorage: IUserStorageClient; children: ReactNode; } @@ -22,6 +22,6 @@ export interface UserStorageProviderProps { * * @public */ -export const UserStorageProvider = ({ client, children }: UserStorageProviderProps) => ( - {children} +export const UserStorageProvider = ({ userStorage, children }: UserStorageProviderProps) => ( + {children} ); From 90b1ff7342c3ed35db30b21461c1ef75036de716 Mon Sep 17 00:00:00 2001 From: Timothy Sullivan Date: Thu, 7 May 2026 19:13:30 -0700 Subject: [PATCH 12/41] [User Storage] Add documentation --- src/core/packages/user-storage/README.mdx | 281 ++++++++++++++++++++++ 1 file changed, 281 insertions(+) create mode 100644 src/core/packages/user-storage/README.mdx diff --git a/src/core/packages/user-storage/README.mdx b/src/core/packages/user-storage/README.mdx new file mode 100644 index 0000000000000..dc4340860562e --- /dev/null +++ b/src/core/packages/user-storage/README.mdx @@ -0,0 +1,281 @@ +--- +id: kibUserStorageService +slug: /kibana-dev-docs/tutorials/user-storage-service +title: User Storage service +description: Per-user, per-space (or global) key/value storage for Kibana plugins, with synchronous reads at first paint. +date: 2026-05-07 +tags: ['kibana', 'dev', 'contributor', 'api docs', 'core', 'user storage', 'preferences'] +--- + +# User Storage Service + +A server-backed, per-user store for plugin state that should follow the user across browsers, devices, and cache clears. Plugins register keys at setup time with a Zod schema, a default value, and a scope (`'space'` or `'global'`); reads on both server and browser are schema-validated, reactive, and — on the browser — synchronous at first paint via rendering injection. + +> **The one-line rule of thumb:** if it's per-user data that should survive a device switch or a cache clear, register it in User Storage. Otherwise, reach for one of the systems described under [Adjacent systems](#adjacent-systems). + +User Storage exists because over 30 plugins were independently writing to `localStorage` for per-user state, each handling serialization, scoping, and validation differently. There was no canonical pattern for "small, schema-validated, per-user data that follows the user." The service fills exactly that gap; it does not replace `localStorage`, `uiSettings`, or ES User Profile, all of which remain the right choice for their respective use cases. + +> ⚠️ Requires an authenticated user with a `profile_uid`. Anonymous pages and API-key requests have no profile: reads return defaults and writes get `403`. Don't build features on User Storage if they need to work without a logged-in user. + +### When to use it + +Per-user preferences that should follow the user across browsers and devices. Example use cases include: + +- **Navigation customization** — reorder, hide, and group nav items per user, per space. +- **Per-user timezone or locale** — override the space-level default without affecting other users. +- **Query language preference** (KQL / Lucene / ES|QL). +- **"Remember last space"** — return to the last space on login. +- **Favorited time ranges** — a small per-user preference list. + +Per-space user state where the same user may want different settings in different spaces is the natural fit for `scope: 'space'`. Cross-space user preferences fit `scope: 'global'`. + +### Adjacent systems + +User Storage co-exists with three other per-user storage layers; pick the one that fits the data, not the one that's most familiar. + +| If the data is… | Use | Why | +|---|---|---| +| Ephemeral UI state — panel widths, collapsed/expanded states, sort orders, view-mode toggles, tour completions, callout dismissals | **`localStorage`** | Trivially re-set; no real cost when lost. Doesn't need to follow the user across devices. | +| Identity / personalization that might follow the user across Elastic Cloud products — dark mode, avatar | **ES User Profile** | ES enforces per-user isolation; field set is small and stable. The right home for portable identity data, not for arbitrary plugin-owned state. | +| Space-wide or cluster-wide admin-set defaults, *not* per-user | **`uiSettings`** | Admin-managed, scoped to a space or cluster. No user dimension. | +| Shared / multi-user data | **Saved Objects** | Has its own visibility and sharing model. | +| Large blobs (>~few KB) | **file service** | Saved Objects are not blob storage. | +| Frequently-changing high-volume telemetry | **analytics** | Persisting every event in a per-user SO is the wrong shape. | +| Settings that must work for unauthenticated users | **`localStorage`** | User Storage requires a `profile_uid`; there is no anonymous fallback today. | + +## Key concepts + +### Registration + +Every key must be **registered** at server setup with a Zod schema, a default value, and a scope. Reads of unregistered keys throw; writes of unregistered keys return `400`. Reads always succeed: if no value is stored, the registered default is returned. + +### Scope + +A key is scoped either to a single space or globally across spaces: + +- `'space'` — the value is stored per `(profile_uid, space_id)`. Use this for state that is meaningfully per-space (e.g. per-solution side-nav customization). +- `'global'` — the value is stored per `profile_uid`, ignoring the active space. Use this for cross-space user preferences (e.g. a one-time tour dismissal). + +Once you pick a scope for a key, changing it later is a breaking change for users with existing values. Choose deliberately. + +### Schema validation + +The Zod schema runs: + +1. At registration, against the `defaultValue` (catches drift between the default and the schema at boot). +2. At every `set`, against the incoming value (rejects invalid writes with `400`). +3. At every `get` / `getAll`, against the stored value. If the stored value fails to parse — usually because the schema has been narrowed since the value was written — the registered default is returned and a warning is logged. This means you can safely tighten a schema without writing a migration, but consumers should treat the resulting value reset as a possible UX outcome. + +### Server-rendered injection + +On every page render, Core calls `userStorage.asScoped(request).getAll()` and inlines the merged map into `` under `userStorage.values`. The browser client uses this map to seed its synchronous cache before the first React render, so consumers can read the user's value during initial paint without a fetch. The fetch is bounded by a 50 ms timeout and falls back to `{}` (everything reads as default) on slow ES or network errors — User Storage will never block first paint. + +## Registering keys (server) + +Register from your plugin's server-side `setup` lifecycle. The same `register()` call can include multiple keys. + +```typescript +import { z } from '@kbn/zod/v4'; +import type { Plugin, CoreSetup } from '@kbn/core/server'; + +export class MyPlugin implements Plugin { + public setup(core: CoreSetup) { + core.userStorage.register({ + 'myPlugin:nav-layout': { + schema: z.object({ + hidden: z.array(z.string()), + order: z.array(z.string()), + }), + defaultValue: { hidden: [], order: [] }, + scope: 'space', + }, + 'myPlugin:tour-dismissed': { + schema: z.boolean(), + defaultValue: false, + scope: 'global', + }, + }); + } + + public start() {} +} +``` + +Each key may only be registered once across all plugins. Duplicate registrations throw at boot. + +## Reading and writing on the server + +`asScoped(request)` returns a client bound to the authenticated user behind a `KibanaRequest`. It returns `null` when the request has no `profile_uid` (typically API-key authentication or anonymous pages); always check. + +```typescript +const client = core.userStorage.asScoped(request); +if (!client) { + return response.forbidden({ body: { message: 'User profile not available' } }); +} + +const layout = await client.get('myPlugin:nav-layout'); +const all = await client.getAll(); // every registered key, merged with defaults + +await client.set('myPlugin:nav-layout', { hidden: ['discover'], order: [] }); +await client.remove('myPlugin:tour-dismissed'); // resets the key to its default +``` + +Server reads always return the resolved value (user override or registered default), never `undefined`. Writes are validated against the registered schema and reject with a Zod error on mismatch. + +### HTTP routes + +Three internal routes are exposed for the browser client; consumers do not normally call them directly: + +| Method | Path | Body | Returns | +|--------|---------------------------------------|-------------------|-------------------------------------------| +| `GET` | `/internal/user_storage` | — | Every registered key merged with defaults | +| `PUT` | `/internal/user_storage/{key}` | `{ value }` | `200` on success, `400` on validation | +| `DELETE` | `/internal/user_storage/{key}` | — | `200` on success | + +All three return `403` when the request has no `profile_uid`. + +## Using the service in the browser + +The browser surface is in `@kbn/core-user-storage-browser` and is exposed on `core.userStorage` at both `setup` and `start`. The client is **synchronous** for reads (cache-backed) and asynchronous for writes (HTTP-backed): + +```typescript +import type { IUserStorageClient } from '@kbn/core-user-storage-browser'; + +// somewhere with access to core: +const layout = core.userStorage.get('myPlugin:nav-layout', defaultLayout); +const all = core.userStorage.getAll(); +await core.userStorage.set('myPlugin:nav-layout', nextLayout); +await core.userStorage.remove('myPlugin:tour-dismissed'); +``` + +Pass a `defaultValue` to `get(key, default)` so that consumers of the value never see `undefined` if the key was not in the injected metadata snapshot (e.g. during anonymous-page rendering). Server-side defaults are the source of truth, but the browser client does not currently know about them — it only knows about cached values. + +The client also exposes RxJS observables for live updates and write errors: + +```typescript +core.userStorage.get$('myPlugin:nav-layout').subscribe((layout) => { + // emits the cached value on subscribe and again on every successful write to this key +}); + +core.userStorage.getUpdate$().subscribe(({ key, newValue, oldValue }) => { + // fires on every successful write across all keys +}); + +core.userStorage.getUpdateErrors$().subscribe((err) => { + // fires when a set/remove HTTP call fails after the call returned to the caller +}); +``` + +### React hook + +Wrap the component tree that needs User Storage in a ``. The hook throws a clear error if no provider is mounted in the tree. + +```tsx +import { + UserStorageProvider, + useUserStorage, + useUserStorageClient, +} from '@kbn/core-user-storage-browser'; + +// In your application's mount root: +const App = ({ core }: { core: CoreStart }) => ( + + + +); + +// In a component: +const NavLayoutEditor = () => { + const [layout, setLayout] = useUserStorage( + 'myPlugin:nav-layout', + { hidden: [], order: [] } + ); + + return ( + + ); +}; +``` + +`useUserStorage(key, defaultValue?)` returns `[value, setter]`. The value reflects the synchronous cache and re-renders on change. The setter persists via HTTP and refreshes the cache on success. If the HTTP write fails, the cache is unchanged, the returned promise rejects, and the error is published to `getUpdateErrors$`. + +For the less common operations — `remove`, `getUpdate$`, `getUpdateErrors$`, multi-key reads — reach for the underlying client: + +```tsx +const client = useUserStorageClient(); + +const onResetTour = () => client.remove('myPlugin:tour-dismissed'); +``` + +## Testing + +Use the mocks in `@kbn/core-user-storage-browser-mocks` (browser) and `@kbn/core-user-storage-server-mocks` (server) instead of stubbing `core.userStorage` by hand. + +```typescript +// Browser: +import { userStorageServiceMock } from '@kbn/core-user-storage-browser-mocks'; + +const userStorage = userStorageServiceMock.createStartContract(); +userStorage.get.mockReturnValue({ hidden: ['discover'], order: [] }); + +render( + + + +); +``` + +```typescript +// Server: +import { userStorageServiceMock } from '@kbn/core-user-storage-server-mocks'; + +const userStorage = userStorageServiceMock.createStartContract(); +userStorage.asScoped.mockReturnValue({ + get: jest.fn().mockResolvedValue({ hidden: ['discover'], order: [] }), + getAll: jest.fn().mockResolvedValue({}), + set: jest.fn().mockResolvedValue(undefined), + remove: jest.fn().mockResolvedValue(undefined), +}); +``` + +The browser mock's `createStartContract()` returns a fully-mocked `IUserStorageClient` where every method is a `jest.fn()`. Override individual methods with `mockReturnValue` / `mockResolvedValue` per test. + +## Caveats and gotchas + +### No fallback for missing `profile_uid` + +If your feature must work for users without a profile (anonymous pages, API-key auth), do not depend on User Storage. The browser cache will be empty and writes will return `403`. Either layer over `localStorage` for those cases or design the feature so missing User Storage is acceptable (e.g. fall back to defaults). + +### Single-tab consistency + +The browser cache is a snapshot taken at page render. If a second tab writes a different value, the first tab will not see it until the next reload. Cross-tab synchronization is on the roadmap but is not implemented today. + +### No optimistic updates + +`set()` does **not** update the cache until the HTTP write succeeds. If your UI needs immediate visual feedback during the in-flight write, manage that intermediate state at the component level — e.g. with a separate "pending value" piece of `useState` — and only commit to the User Storage value on success. + +### Schema migrations + +There is no built-in migration framework. To evolve a schema: + +- **Widening** (e.g. adding an optional field) is safe — old values still parse. +- **Narrowing** (e.g. tightening a regex, changing required fields) will cause stored values that no longer match to be replaced by the registered default at read time, with a warning. This is forward-compatible but lossy for the affected user. +- For non-trivial migrations, register a new key (`myPlugin:nav-layout-v2`), copy values lazily on read, and deprecate the old key. + +### Don't store secrets + +User Storage values are persisted in unencrypted system Saved Objects. Treat them as user-readable; do not persist tokens, credentials, or anything that should be encrypted at rest. + +## Reference + +| Package | Visibility | Contents | +|-----------------------------------------------|------------|-----------------------------------------------------------------------| +| `@kbn/core-user-storage-common` | shared | `UserStorageDefinition`, `UserStorageScope`, server `IUserStorageClient` | +| `@kbn/core-user-storage-server` | shared | `UserStorageServiceSetup`, `UserStorageServiceStart` | +| `@kbn/core-user-storage-browser` | shared | Browser `IUserStorageClient`, `UserStorageProvider`, `useUserStorage`, `useUserStorageClient` | +| `@kbn/core-user-storage-server-mocks` | dev only | `userStorageServiceMock` (server) | +| `@kbn/core-user-storage-browser-mocks` | dev only | `userStorageServiceMock` (browser) | +| `@kbn/core-user-storage-server-internal` | private | Server implementation | +| `@kbn/core-user-storage-browser-internal` | private | Browser implementation | \ No newline at end of file From a9ecfb0e0297d2e01dcafffea4c7c25b8477f174 Mon Sep 17 00:00:00 2001 From: Timothy Sullivan Date: Thu, 7 May 2026 22:25:34 -0700 Subject: [PATCH 13/41] cleanup --- .../injected-metadata/common-internal/src/types.ts | 6 ------ .../server-internal/src/rendering_service.test.ts | 8 -------- 2 files changed, 14 deletions(-) diff --git a/src/core/packages/injected-metadata/common-internal/src/types.ts b/src/core/packages/injected-metadata/common-internal/src/types.ts index 62c7b08d011fa..4dd9f115a0c95 100644 --- a/src/core/packages/injected-metadata/common-internal/src/types.ts +++ b/src/core/packages/injected-metadata/common-internal/src/types.ts @@ -93,12 +93,6 @@ export interface InjectedMetadata { }; }; customBranding: Pick; - /** - * Per-user values pre-fetched on the server for keys registered with the - * user storage service. Empty on anonymous pages or when the request has - * no `profile_uid`. Browser code should treat the values as a snapshot at - * page load and fall back to registered defaults for missing keys. - */ userStorage: { values: Record; }; diff --git a/src/core/packages/rendering/server-internal/src/rendering_service.test.ts b/src/core/packages/rendering/server-internal/src/rendering_service.test.ts index 7d0c4d183f742..cbc4cb892228b 100644 --- a/src/core/packages/rendering/server-internal/src/rendering_service.test.ts +++ b/src/core/packages/rendering/server-internal/src/rendering_service.test.ts @@ -763,14 +763,6 @@ describe('RenderingService', () => { }); }); - it('injects empty values when start() was never called (no userStorageStart)', async () => { - const { render } = await service.setup(mockRenderingSetupDeps); - - const content = await render(createKibanaRequest(), buildUiSettings()); - - expect(await renderAndReadUserStorage(content)).toEqual({ values: {} }); - }); - it('injects empty values when asScoped() returns null (no profile_uid)', async () => { const { render } = await service.setup(mockRenderingSetupDeps); From e1b7da518fd24010a6271444664531dbb533e965 Mon Sep 17 00:00:00 2001 From: Timothy Sullivan Date: Fri, 8 May 2026 17:38:40 -0700 Subject: [PATCH 14/41] scout - plugin test --- .../plugins/user_storage_test/kibana.jsonc | 2 +- .../test/plugins/user_storage_test/moon.yml | 3 ++ .../user_storage_test/public/application.tsx | 18 +++++++++++ .../public/components/app.tsx | 32 +++++++++++++++++++ .../plugins/user_storage_test/public/index.ts | 12 +++++++ .../user_storage_test/public/plugin.ts | 28 ++++++++++++++++ .../plugins/user_storage_test/tsconfig.json | 3 ++ .../ui/playwright.config.ts | 14 ++++++++ .../ui/tests/render.spec.ts | 24 ++++++++++++++ .../test/scout_user_storage/ui/tsconfig.json | 11 +++++++ 10 files changed, 146 insertions(+), 1 deletion(-) create mode 100644 src/core/packages/user-storage/test/plugins/user_storage_test/public/application.tsx create mode 100644 src/core/packages/user-storage/test/plugins/user_storage_test/public/components/app.tsx create mode 100644 src/core/packages/user-storage/test/plugins/user_storage_test/public/index.ts create mode 100644 src/core/packages/user-storage/test/plugins/user_storage_test/public/plugin.ts create mode 100644 src/core/packages/user-storage/test/scout_user_storage/ui/playwright.config.ts create mode 100644 src/core/packages/user-storage/test/scout_user_storage/ui/tests/render.spec.ts create mode 100644 src/core/packages/user-storage/test/scout_user_storage/ui/tsconfig.json diff --git a/src/core/packages/user-storage/test/plugins/user_storage_test/kibana.jsonc b/src/core/packages/user-storage/test/plugins/user_storage_test/kibana.jsonc index a633994b31eae..70c0bc7afa223 100644 --- a/src/core/packages/user-storage/test/plugins/user_storage_test/kibana.jsonc +++ b/src/core/packages/user-storage/test/plugins/user_storage_test/kibana.jsonc @@ -7,7 +7,7 @@ "plugin": { "id": "userStorageTest", "server": true, - "browser": false, + "browser": true, "configPath": [ "user_storage_test" ] diff --git a/src/core/packages/user-storage/test/plugins/user_storage_test/moon.yml b/src/core/packages/user-storage/test/plugins/user_storage_test/moon.yml index e50612c364368..d5794e5d8a233 100644 --- a/src/core/packages/user-storage/test/plugins/user_storage_test/moon.yml +++ b/src/core/packages/user-storage/test/plugins/user_storage_test/moon.yml @@ -18,6 +18,7 @@ project: sourceRoot: src/core/packages/user-storage/test/plugins/user_storage_test dependsOn: - '@kbn/core' + - '@kbn/core-user-storage-browser' - '@kbn/zod' tags: - plugin @@ -27,5 +28,7 @@ tags: fileGroups: src: - server/**/*.ts + - public/**/*.ts + - public/**/*.tsx - '!target/**/*' tasks: {} diff --git a/src/core/packages/user-storage/test/plugins/user_storage_test/public/application.tsx b/src/core/packages/user-storage/test/plugins/user_storage_test/public/application.tsx new file mode 100644 index 0000000000000..1d1cbf6c958a3 --- /dev/null +++ b/src/core/packages/user-storage/test/plugins/user_storage_test/public/application.tsx @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React from 'react'; +import ReactDOM from 'react-dom'; +import type { AppMountParameters, CoreStart } from '@kbn/core/public'; +import { App } from './components/app'; + +export const renderApp = (core: CoreStart, { element }: AppMountParameters) => { + ReactDOM.render(, element); + return () => ReactDOM.unmountComponentAtNode(element); +}; diff --git a/src/core/packages/user-storage/test/plugins/user_storage_test/public/components/app.tsx b/src/core/packages/user-storage/test/plugins/user_storage_test/public/components/app.tsx new file mode 100644 index 0000000000000..312df1262054e --- /dev/null +++ b/src/core/packages/user-storage/test/plugins/user_storage_test/public/components/app.tsx @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React from 'react'; +import { + UserStorageProvider, + useUserStorage, + type IUserStorageClient, +} from '@kbn/core-user-storage-browser'; + +const StringKeyValue = () => { + const [value] = useUserStorage('test:string_key'); + return ( +
+ String key: + {value} +
+ ); +}; + +export const App = ({ userStorage }: { userStorage: IUserStorageClient }) => ( + +

User Storage Test

+ +
+); diff --git a/src/core/packages/user-storage/test/plugins/user_storage_test/public/index.ts b/src/core/packages/user-storage/test/plugins/user_storage_test/public/index.ts new file mode 100644 index 0000000000000..7be32acae853c --- /dev/null +++ b/src/core/packages/user-storage/test/plugins/user_storage_test/public/index.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { UserStorageTestPlugin } from './plugin'; + +export const plugin = () => new UserStorageTestPlugin(); diff --git a/src/core/packages/user-storage/test/plugins/user_storage_test/public/plugin.ts b/src/core/packages/user-storage/test/plugins/user_storage_test/public/plugin.ts new file mode 100644 index 0000000000000..24f52856a69e4 --- /dev/null +++ b/src/core/packages/user-storage/test/plugins/user_storage_test/public/plugin.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { AppMountParameters, CoreSetup, Plugin } from '@kbn/core/public'; + +export class UserStorageTestPlugin implements Plugin { + public setup(core: CoreSetup): void { + core.application.register({ + id: 'userStorageTest', + title: 'User Storage Test', + async mount(params: AppMountParameters) { + const { renderApp } = await import('./application'); + const [coreStart] = await core.getStartServices(); + return renderApp(coreStart, params); + }, + }); + } + + public start() {} + + public stop() {} +} diff --git a/src/core/packages/user-storage/test/plugins/user_storage_test/tsconfig.json b/src/core/packages/user-storage/test/plugins/user_storage_test/tsconfig.json index d29853c8a0fcf..afc276d647e92 100644 --- a/src/core/packages/user-storage/test/plugins/user_storage_test/tsconfig.json +++ b/src/core/packages/user-storage/test/plugins/user_storage_test/tsconfig.json @@ -5,6 +5,8 @@ }, "include": [ "server/**/*.ts", + "public/**/*.ts", + "public/**/*.tsx", "../../../../../../../../typings/**/*", ], "exclude": [ @@ -12,6 +14,7 @@ ], "kbn_references": [ "@kbn/core", + "@kbn/core-user-storage-browser", "@kbn/zod", ] } diff --git a/src/core/packages/user-storage/test/scout_user_storage/ui/playwright.config.ts b/src/core/packages/user-storage/test/scout_user_storage/ui/playwright.config.ts new file mode 100644 index 0000000000000..72df09a207161 --- /dev/null +++ b/src/core/packages/user-storage/test/scout_user_storage/ui/playwright.config.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { createPlaywrightConfig } from '@kbn/scout'; + +export default createPlaywrightConfig({ + testDir: './tests', +}); diff --git a/src/core/packages/user-storage/test/scout_user_storage/ui/tests/render.spec.ts b/src/core/packages/user-storage/test/scout_user_storage/ui/tests/render.spec.ts new file mode 100644 index 0000000000000..f855b4829cd6f --- /dev/null +++ b/src/core/packages/user-storage/test/scout_user_storage/ui/tests/render.spec.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { test, tags } from '@kbn/scout'; +import { expect } from '@kbn/scout/ui'; + +test.describe('User Storage - first paint', { tag: [...tags.stateful.classic] }, () => { + test.beforeEach(async ({ browserAuth, page }) => { + await browserAuth.loginAsViewer(); + await page.gotoApp('userStorageTest'); + }); + + test('renders the registered default for test:string_key on first paint', async ({ page }) => { + await expect(page.testSubj.locator('userStorageTest:string-key-value')).toHaveText( + 'default_value' + ); + }); +}); diff --git a/src/core/packages/user-storage/test/scout_user_storage/ui/tsconfig.json b/src/core/packages/user-storage/test/scout_user_storage/ui/tsconfig.json new file mode 100644 index 0000000000000..42a57a45bc83e --- /dev/null +++ b/src/core/packages/user-storage/test/scout_user_storage/ui/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "@kbn/tsconfig-base/tsconfig.json", + "compilerOptions": { + "outDir": "target/types" + }, + "include": ["**/*"], + "kbn_references": [ + "@kbn/scout" + ], + "exclude": ["target/**/*"] +} From 97de6a975199b50aa4a25eed43fa998b120cad74 Mon Sep 17 00:00:00 2001 From: Timothy Sullivan Date: Fri, 8 May 2026 17:43:39 -0700 Subject: [PATCH 15/41] scout cleanup --- .../scout_user_storage/api/tests/crud.spec.ts | 3 +- .../scout_user_storage/api/tests/helpers.ts | 20 ------------ .../api/tests/scope_isolation.spec.ts | 3 +- .../api/tests/validation.spec.ts | 3 +- .../test/scout_user_storage/api/tsconfig.json | 2 +- .../scout_user_storage/shared/test_keys.ts | 32 +++++++++++++++++++ .../ui/tests/render.spec.ts | 3 +- .../test/scout_user_storage/ui/tsconfig.json | 2 +- 8 files changed, 42 insertions(+), 26 deletions(-) create mode 100644 src/core/packages/user-storage/test/scout_user_storage/shared/test_keys.ts diff --git a/src/core/packages/user-storage/test/scout_user_storage/api/tests/crud.spec.ts b/src/core/packages/user-storage/test/scout_user_storage/api/tests/crud.spec.ts index 5801794b742f6..03a583d3d10c5 100644 --- a/src/core/packages/user-storage/test/scout_user_storage/api/tests/crud.spec.ts +++ b/src/core/packages/user-storage/test/scout_user_storage/api/tests/crud.spec.ts @@ -10,7 +10,8 @@ import { tags } from '@kbn/scout'; import { expect } from '@kbn/scout/api'; import { apiTest } from '../fixtures'; -import { ALL_KEYS, DEFAULT_VALUES, createHelpers } from './helpers'; +import { ALL_KEYS, DEFAULT_VALUES } from '../../shared/test_keys'; +import { createHelpers } from './helpers'; apiTest.describe('User Storage - CRUD', { tag: [...tags.stateful.classic] }, () => { let cookieHeader: Record; diff --git a/src/core/packages/user-storage/test/scout_user_storage/api/tests/helpers.ts b/src/core/packages/user-storage/test/scout_user_storage/api/tests/helpers.ts index b16dfc641a168..bd8ba13ff5498 100644 --- a/src/core/packages/user-storage/test/scout_user_storage/api/tests/helpers.ts +++ b/src/core/packages/user-storage/test/scout_user_storage/api/tests/helpers.ts @@ -9,26 +9,6 @@ import { INTERNAL_HEADERS } from '../fixtures'; -export const ALL_KEYS = [ - 'test:string_key', - 'test:number_key', - 'test:object_key', - 'test:boolean_key', - 'test:array_key', -]; - -export const DEFAULT_VALUES: Record = { - 'test:string_key': 'default_value', - 'test:number_key': 42, - 'test:object_key': { - theme: 'light', - sidebar: { collapsed: false, width: 250 }, - pinnedItems: [], - }, - 'test:boolean_key': false, - 'test:array_key': [], -}; - interface ApiClientOptions { headers?: Record; body?: unknown; diff --git a/src/core/packages/user-storage/test/scout_user_storage/api/tests/scope_isolation.spec.ts b/src/core/packages/user-storage/test/scout_user_storage/api/tests/scope_isolation.spec.ts index a1cf56ab2de5a..47c2768561e1a 100644 --- a/src/core/packages/user-storage/test/scout_user_storage/api/tests/scope_isolation.spec.ts +++ b/src/core/packages/user-storage/test/scout_user_storage/api/tests/scope_isolation.spec.ts @@ -10,7 +10,8 @@ import { tags } from '@kbn/scout'; import { expect } from '@kbn/scout/api'; import { apiTest } from '../fixtures'; -import { ALL_KEYS, createHelpers } from './helpers'; +import { ALL_KEYS } from '../../shared/test_keys'; +import { createHelpers } from './helpers'; const TEST_SPACE = 'test-user-storage'; diff --git a/src/core/packages/user-storage/test/scout_user_storage/api/tests/validation.spec.ts b/src/core/packages/user-storage/test/scout_user_storage/api/tests/validation.spec.ts index 7a3d2093b7eb0..de7c8cbe49cb8 100644 --- a/src/core/packages/user-storage/test/scout_user_storage/api/tests/validation.spec.ts +++ b/src/core/packages/user-storage/test/scout_user_storage/api/tests/validation.spec.ts @@ -10,7 +10,8 @@ import { tags } from '@kbn/scout'; import { expect } from '@kbn/scout/api'; import { apiTest } from '../fixtures'; -import { ALL_KEYS, createHelpers } from './helpers'; +import { ALL_KEYS } from '../../shared/test_keys'; +import { createHelpers } from './helpers'; apiTest.describe('User Storage - Schema Validation', { tag: [...tags.stateful.classic] }, () => { let cookieHeader: Record; diff --git a/src/core/packages/user-storage/test/scout_user_storage/api/tsconfig.json b/src/core/packages/user-storage/test/scout_user_storage/api/tsconfig.json index 42a57a45bc83e..5dbd01338303d 100644 --- a/src/core/packages/user-storage/test/scout_user_storage/api/tsconfig.json +++ b/src/core/packages/user-storage/test/scout_user_storage/api/tsconfig.json @@ -3,7 +3,7 @@ "compilerOptions": { "outDir": "target/types" }, - "include": ["**/*"], + "include": ["**/*", "../shared/**/*"], "kbn_references": [ "@kbn/scout" ], diff --git a/src/core/packages/user-storage/test/scout_user_storage/shared/test_keys.ts b/src/core/packages/user-storage/test/scout_user_storage/shared/test_keys.ts new file mode 100644 index 0000000000000..842da0de0200c --- /dev/null +++ b/src/core/packages/user-storage/test/scout_user_storage/shared/test_keys.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +// Mirrors the `register()` call in +// `src/core/packages/user-storage/test/plugins/user_storage_test/server/plugin.ts`. +// Update both files together when changing the test fixture. + +export const ALL_KEYS = [ + 'test:string_key', + 'test:number_key', + 'test:object_key', + 'test:boolean_key', + 'test:array_key', +] as const; + +export const DEFAULT_VALUES: Record = { + 'test:string_key': 'default_value', + 'test:number_key': 42, + 'test:object_key': { + theme: 'light', + sidebar: { collapsed: false, width: 250 }, + pinnedItems: [], + }, + 'test:boolean_key': false, + 'test:array_key': [], +}; diff --git a/src/core/packages/user-storage/test/scout_user_storage/ui/tests/render.spec.ts b/src/core/packages/user-storage/test/scout_user_storage/ui/tests/render.spec.ts index f855b4829cd6f..5caeb2479b185 100644 --- a/src/core/packages/user-storage/test/scout_user_storage/ui/tests/render.spec.ts +++ b/src/core/packages/user-storage/test/scout_user_storage/ui/tests/render.spec.ts @@ -9,6 +9,7 @@ import { test, tags } from '@kbn/scout'; import { expect } from '@kbn/scout/ui'; +import { DEFAULT_VALUES } from '../../shared/test_keys'; test.describe('User Storage - first paint', { tag: [...tags.stateful.classic] }, () => { test.beforeEach(async ({ browserAuth, page }) => { @@ -18,7 +19,7 @@ test.describe('User Storage - first paint', { tag: [...tags.stateful.classic] }, test('renders the registered default for test:string_key on first paint', async ({ page }) => { await expect(page.testSubj.locator('userStorageTest:string-key-value')).toHaveText( - 'default_value' + DEFAULT_VALUES['test:string_key'] as string ); }); }); diff --git a/src/core/packages/user-storage/test/scout_user_storage/ui/tsconfig.json b/src/core/packages/user-storage/test/scout_user_storage/ui/tsconfig.json index 42a57a45bc83e..5dbd01338303d 100644 --- a/src/core/packages/user-storage/test/scout_user_storage/ui/tsconfig.json +++ b/src/core/packages/user-storage/test/scout_user_storage/ui/tsconfig.json @@ -3,7 +3,7 @@ "compilerOptions": { "outDir": "target/types" }, - "include": ["**/*"], + "include": ["**/*", "../shared/**/*"], "kbn_references": [ "@kbn/scout" ], From 71a036cb1cfd21a3d134fa20cd8879aa0508e117 Mon Sep 17 00:00:00 2001 From: Timothy Sullivan Date: Sat, 9 May 2026 09:29:29 -0700 Subject: [PATCH 16/41] fix rootDir issue (add minor duplications) --- .../scout_user_storage/api/tests/crud.spec.ts | 3 +- .../scout_user_storage/api/tests/helpers.ts | 23 +++++++++++++ .../api/tests/scope_isolation.spec.ts | 3 +- .../api/tests/validation.spec.ts | 3 +- .../test/scout_user_storage/api/tsconfig.json | 2 +- .../scout_user_storage/shared/test_keys.ts | 32 ------------------- .../ui/tests/render.spec.ts | 8 +++-- .../test/scout_user_storage/ui/tsconfig.json | 2 +- 8 files changed, 34 insertions(+), 42 deletions(-) delete mode 100644 src/core/packages/user-storage/test/scout_user_storage/shared/test_keys.ts diff --git a/src/core/packages/user-storage/test/scout_user_storage/api/tests/crud.spec.ts b/src/core/packages/user-storage/test/scout_user_storage/api/tests/crud.spec.ts index 03a583d3d10c5..5801794b742f6 100644 --- a/src/core/packages/user-storage/test/scout_user_storage/api/tests/crud.spec.ts +++ b/src/core/packages/user-storage/test/scout_user_storage/api/tests/crud.spec.ts @@ -10,8 +10,7 @@ import { tags } from '@kbn/scout'; import { expect } from '@kbn/scout/api'; import { apiTest } from '../fixtures'; -import { ALL_KEYS, DEFAULT_VALUES } from '../../shared/test_keys'; -import { createHelpers } from './helpers'; +import { ALL_KEYS, DEFAULT_VALUES, createHelpers } from './helpers'; apiTest.describe('User Storage - CRUD', { tag: [...tags.stateful.classic] }, () => { let cookieHeader: Record; diff --git a/src/core/packages/user-storage/test/scout_user_storage/api/tests/helpers.ts b/src/core/packages/user-storage/test/scout_user_storage/api/tests/helpers.ts index bd8ba13ff5498..5f276c4faa23e 100644 --- a/src/core/packages/user-storage/test/scout_user_storage/api/tests/helpers.ts +++ b/src/core/packages/user-storage/test/scout_user_storage/api/tests/helpers.ts @@ -9,6 +9,29 @@ import { INTERNAL_HEADERS } from '../fixtures'; +// Mirrors the `register()` call in +// `src/core/packages/user-storage/test/plugins/user_storage_test/server/plugin.ts`. +// Keep both in sync when changing the test fixture. +export const ALL_KEYS = [ + 'test:string_key', + 'test:number_key', + 'test:object_key', + 'test:boolean_key', + 'test:array_key', +]; + +export const DEFAULT_VALUES: Record = { + 'test:string_key': 'default_value', + 'test:number_key': 42, + 'test:object_key': { + theme: 'light', + sidebar: { collapsed: false, width: 250 }, + pinnedItems: [], + }, + 'test:boolean_key': false, + 'test:array_key': [], +}; + interface ApiClientOptions { headers?: Record; body?: unknown; diff --git a/src/core/packages/user-storage/test/scout_user_storage/api/tests/scope_isolation.spec.ts b/src/core/packages/user-storage/test/scout_user_storage/api/tests/scope_isolation.spec.ts index 47c2768561e1a..a1cf56ab2de5a 100644 --- a/src/core/packages/user-storage/test/scout_user_storage/api/tests/scope_isolation.spec.ts +++ b/src/core/packages/user-storage/test/scout_user_storage/api/tests/scope_isolation.spec.ts @@ -10,8 +10,7 @@ import { tags } from '@kbn/scout'; import { expect } from '@kbn/scout/api'; import { apiTest } from '../fixtures'; -import { ALL_KEYS } from '../../shared/test_keys'; -import { createHelpers } from './helpers'; +import { ALL_KEYS, createHelpers } from './helpers'; const TEST_SPACE = 'test-user-storage'; diff --git a/src/core/packages/user-storage/test/scout_user_storage/api/tests/validation.spec.ts b/src/core/packages/user-storage/test/scout_user_storage/api/tests/validation.spec.ts index de7c8cbe49cb8..7a3d2093b7eb0 100644 --- a/src/core/packages/user-storage/test/scout_user_storage/api/tests/validation.spec.ts +++ b/src/core/packages/user-storage/test/scout_user_storage/api/tests/validation.spec.ts @@ -10,8 +10,7 @@ import { tags } from '@kbn/scout'; import { expect } from '@kbn/scout/api'; import { apiTest } from '../fixtures'; -import { ALL_KEYS } from '../../shared/test_keys'; -import { createHelpers } from './helpers'; +import { ALL_KEYS, createHelpers } from './helpers'; apiTest.describe('User Storage - Schema Validation', { tag: [...tags.stateful.classic] }, () => { let cookieHeader: Record; diff --git a/src/core/packages/user-storage/test/scout_user_storage/api/tsconfig.json b/src/core/packages/user-storage/test/scout_user_storage/api/tsconfig.json index 5dbd01338303d..42a57a45bc83e 100644 --- a/src/core/packages/user-storage/test/scout_user_storage/api/tsconfig.json +++ b/src/core/packages/user-storage/test/scout_user_storage/api/tsconfig.json @@ -3,7 +3,7 @@ "compilerOptions": { "outDir": "target/types" }, - "include": ["**/*", "../shared/**/*"], + "include": ["**/*"], "kbn_references": [ "@kbn/scout" ], diff --git a/src/core/packages/user-storage/test/scout_user_storage/shared/test_keys.ts b/src/core/packages/user-storage/test/scout_user_storage/shared/test_keys.ts deleted file mode 100644 index 842da0de0200c..0000000000000 --- a/src/core/packages/user-storage/test/scout_user_storage/shared/test_keys.ts +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -// Mirrors the `register()` call in -// `src/core/packages/user-storage/test/plugins/user_storage_test/server/plugin.ts`. -// Update both files together when changing the test fixture. - -export const ALL_KEYS = [ - 'test:string_key', - 'test:number_key', - 'test:object_key', - 'test:boolean_key', - 'test:array_key', -] as const; - -export const DEFAULT_VALUES: Record = { - 'test:string_key': 'default_value', - 'test:number_key': 42, - 'test:object_key': { - theme: 'light', - sidebar: { collapsed: false, width: 250 }, - pinnedItems: [], - }, - 'test:boolean_key': false, - 'test:array_key': [], -}; diff --git a/src/core/packages/user-storage/test/scout_user_storage/ui/tests/render.spec.ts b/src/core/packages/user-storage/test/scout_user_storage/ui/tests/render.spec.ts index 5caeb2479b185..10f90c7b97f80 100644 --- a/src/core/packages/user-storage/test/scout_user_storage/ui/tests/render.spec.ts +++ b/src/core/packages/user-storage/test/scout_user_storage/ui/tests/render.spec.ts @@ -9,7 +9,11 @@ import { test, tags } from '@kbn/scout'; import { expect } from '@kbn/scout/ui'; -import { DEFAULT_VALUES } from '../../shared/test_keys'; + +// Mirrors the `register()` call in +// `src/core/packages/user-storage/test/plugins/user_storage_test/server/plugin.ts`. +// Keep both in sync when changing the test fixture. +const TEST_STRING_KEY_DEFAULT = 'default_value'; test.describe('User Storage - first paint', { tag: [...tags.stateful.classic] }, () => { test.beforeEach(async ({ browserAuth, page }) => { @@ -19,7 +23,7 @@ test.describe('User Storage - first paint', { tag: [...tags.stateful.classic] }, test('renders the registered default for test:string_key on first paint', async ({ page }) => { await expect(page.testSubj.locator('userStorageTest:string-key-value')).toHaveText( - DEFAULT_VALUES['test:string_key'] as string + TEST_STRING_KEY_DEFAULT ); }); }); diff --git a/src/core/packages/user-storage/test/scout_user_storage/ui/tsconfig.json b/src/core/packages/user-storage/test/scout_user_storage/ui/tsconfig.json index 5dbd01338303d..42a57a45bc83e 100644 --- a/src/core/packages/user-storage/test/scout_user_storage/ui/tsconfig.json +++ b/src/core/packages/user-storage/test/scout_user_storage/ui/tsconfig.json @@ -3,7 +3,7 @@ "compilerOptions": { "outDir": "target/types" }, - "include": ["**/*", "../shared/**/*"], + "include": ["**/*"], "kbn_references": [ "@kbn/scout" ], From 78c8e35a1934c1d6f3fe155879d6b0e2a3610e2a Mon Sep 17 00:00:00 2001 From: Timothy Sullivan Date: Sat, 9 May 2026 09:46:54 -0700 Subject: [PATCH 17/41] add test plugin to limits --- packages/kbn-optimizer/limits.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 2112f6f5c4e25..e2e2773f6dd3d 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -195,6 +195,7 @@ pageLoadAssetSize: urlDrilldown: 5852 urlForwarding: 7349 usageCollection: 5655 + userStorageTest: 3864 ux: 8376 visDefaultEditor: 35080 visTypeGauge: 13006 From 7303ff75ae31f79754fba5f667952aa19375bbfb Mon Sep 17 00:00:00 2001 From: Timothy Sullivan Date: Mon, 18 May 2026 15:08:22 -0700 Subject: [PATCH 18/41] relocate test plugin --- api_docs/plugin_directory.mdx | 1 - package.json | 2 +- packages/kbn-optimizer/limits.yml | 1 - .../test/scout_user_storage/api/tests/helpers.ts | 2 +- .../test/scout_user_storage/ui/tests/render.spec.ts | 2 +- .../user_storage/stateful/classic.stateful.config.ts | 2 +- .../user_storage}/plugins/user_storage_test/kibana.jsonc | 0 .../test/user_storage}/plugins/user_storage_test/moon.yml | 2 +- .../user_storage}/plugins/user_storage_test/package.json | 6 +++--- .../plugins/user_storage_test/public/application.tsx | 0 .../plugins/user_storage_test/public/components/app.tsx | 0 .../user_storage}/plugins/user_storage_test/public/index.ts | 0 .../plugins/user_storage_test/public/plugin.ts | 0 .../user_storage}/plugins/user_storage_test/server/index.ts | 0 .../plugins/user_storage_test/server/plugin.ts | 0 .../user_storage}/plugins/user_storage_test/tsconfig.json | 2 +- tsconfig.base.json | 4 ++-- 17 files changed, 11 insertions(+), 13 deletions(-) rename src/{core/packages/user-storage/test => platform/test/user_storage}/plugins/user_storage_test/kibana.jsonc (100%) rename src/{core/packages/user-storage/test => platform/test/user_storage}/plugins/user_storage_test/moon.yml (92%) rename src/{core/packages/user-storage/test => platform/test/user_storage}/plugins/user_storage_test/package.json (54%) rename src/{core/packages/user-storage/test => platform/test/user_storage}/plugins/user_storage_test/public/application.tsx (100%) rename src/{core/packages/user-storage/test => platform/test/user_storage}/plugins/user_storage_test/public/components/app.tsx (100%) rename src/{core/packages/user-storage/test => platform/test/user_storage}/plugins/user_storage_test/public/index.ts (100%) rename src/{core/packages/user-storage/test => platform/test/user_storage}/plugins/user_storage_test/public/plugin.ts (100%) rename src/{core/packages/user-storage/test => platform/test/user_storage}/plugins/user_storage_test/server/index.ts (100%) rename src/{core/packages/user-storage/test => platform/test/user_storage}/plugins/user_storage_test/server/plugin.ts (100%) rename src/{core/packages/user-storage/test => platform/test/user_storage}/plugins/user_storage_test/tsconfig.json (88%) diff --git a/api_docs/plugin_directory.mdx b/api_docs/plugin_directory.mdx index 504572aaca171..dbefecc2157fb 100644 --- a/api_docs/plugin_directory.mdx +++ b/api_docs/plugin_directory.mdx @@ -246,7 +246,6 @@ tags: ['contributor', 'dev', 'apidocs', 'kibana'] | | [@elastic/kibana-visualizations](https://github.com/orgs/elastic/teams/kibana-visualizations) | - | 12 | 0 | 12 | 0 | | | [@elastic/kibana-core](https://github.com/orgs/elastic/teams/kibana-core) | - | 30 | 0 | 10 | 1 | | | [@elastic/kibana-core](https://github.com/orgs/elastic/teams/kibana-core) | - | 52 | 0 | 11 | 5 | -| userStorageTest | [@elastic/appex-sharedux](https://github.com/orgs/elastic/teams/appex-sharedux) | - | 0 | 0 | 0 | 0 | | | [@elastic/actionable-obs-team](https://github.com/orgs/elastic/teams/actionable-obs-team) | - | 2 | 0 | 2 | 0 | | | [@elastic/kibana-visualizations](https://github.com/orgs/elastic/teams/kibana-visualizations) | The default editor used in most aggregation-based visualizations. | 79 | 0 | 70 | 4 | | | [@elastic/kibana-visualizations](https://github.com/orgs/elastic/teams/kibana-visualizations) | Contains the gauge chart implementation using the elastic-charts library. The goal is to eventually deprecate the old implementation and keep only this. Until then, the library used is defined by the Legacy charts library advanced setting. | 7 | 0 | 7 | 2 | diff --git a/package.json b/package.json index 598d82bbd733e..35fc29805e043 100644 --- a/package.json +++ b/package.json @@ -1244,7 +1244,7 @@ "@kbn/user-profile-components": "link:src/platform/packages/shared/kbn-user-profile-components", "@kbn/user-profile-examples-plugin": "link:examples/user_profile_examples", "@kbn/user-profiles-consumer-plugin": "link:x-pack/platform/test/security_api_integration/plugins/user_profiles_consumer", - "@kbn/user-storage-test-plugin": "link:src/core/packages/user-storage/test/plugins/user_storage_test", + "@kbn/user-storage-test-plugin": "link:src/platform/test/user_storage/plugins/user_storage_test", "@kbn/utility-types": "link:src/platform/packages/shared/kbn-utility-types", "@kbn/utility-types-jest": "link:src/platform/packages/shared/kbn-utility-types-jest", "@kbn/utils": "link:src/platform/packages/shared/kbn-utils", diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 7b752cf6840a1..a0fdf1fae2b2a 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -195,7 +195,6 @@ pageLoadAssetSize: urlDrilldown: 5852 urlForwarding: 7349 usageCollection: 5655 - userStorageTest: 3864 ux: 8376 visDefaultEditor: 35080 visTypeGauge: 13006 diff --git a/src/core/packages/user-storage/test/scout_user_storage/api/tests/helpers.ts b/src/core/packages/user-storage/test/scout_user_storage/api/tests/helpers.ts index 5f276c4faa23e..364714adfd49f 100644 --- a/src/core/packages/user-storage/test/scout_user_storage/api/tests/helpers.ts +++ b/src/core/packages/user-storage/test/scout_user_storage/api/tests/helpers.ts @@ -10,7 +10,7 @@ import { INTERNAL_HEADERS } from '../fixtures'; // Mirrors the `register()` call in -// `src/core/packages/user-storage/test/plugins/user_storage_test/server/plugin.ts`. +// `src/platform/test/user_storage/plugins/user_storage_test/server/plugin.ts`. // Keep both in sync when changing the test fixture. export const ALL_KEYS = [ 'test:string_key', diff --git a/src/core/packages/user-storage/test/scout_user_storage/ui/tests/render.spec.ts b/src/core/packages/user-storage/test/scout_user_storage/ui/tests/render.spec.ts index 10f90c7b97f80..0cf7f62aae121 100644 --- a/src/core/packages/user-storage/test/scout_user_storage/ui/tests/render.spec.ts +++ b/src/core/packages/user-storage/test/scout_user_storage/ui/tests/render.spec.ts @@ -11,7 +11,7 @@ import { test, tags } from '@kbn/scout'; import { expect } from '@kbn/scout/ui'; // Mirrors the `register()` call in -// `src/core/packages/user-storage/test/plugins/user_storage_test/server/plugin.ts`. +// `src/platform/test/user_storage/plugins/user_storage_test/server/plugin.ts`. // Keep both in sync when changing the test fixture. const TEST_STRING_KEY_DEFAULT = 'default_value'; diff --git a/src/platform/packages/shared/kbn-scout/src/servers/configs/config_sets/user_storage/stateful/classic.stateful.config.ts b/src/platform/packages/shared/kbn-scout/src/servers/configs/config_sets/user_storage/stateful/classic.stateful.config.ts index efa99a8048df9..ad5c59f4601fd 100644 --- a/src/platform/packages/shared/kbn-scout/src/servers/configs/config_sets/user_storage/stateful/classic.stateful.config.ts +++ b/src/platform/packages/shared/kbn-scout/src/servers/configs/config_sets/user_storage/stateful/classic.stateful.config.ts @@ -20,7 +20,7 @@ export const servers: ScoutServerConfig = { ...defaultConfig.kbnTestServer.serverArgs, `--plugin-path=${resolve( REPO_ROOT, - 'src/core/packages/user-storage/test/plugins/user_storage_test' + 'src/platform/test/user_storage/plugins/user_storage_test' )}`, ], }, diff --git a/src/core/packages/user-storage/test/plugins/user_storage_test/kibana.jsonc b/src/platform/test/user_storage/plugins/user_storage_test/kibana.jsonc similarity index 100% rename from src/core/packages/user-storage/test/plugins/user_storage_test/kibana.jsonc rename to src/platform/test/user_storage/plugins/user_storage_test/kibana.jsonc diff --git a/src/core/packages/user-storage/test/plugins/user_storage_test/moon.yml b/src/platform/test/user_storage/plugins/user_storage_test/moon.yml similarity index 92% rename from src/core/packages/user-storage/test/plugins/user_storage_test/moon.yml rename to src/platform/test/user_storage/plugins/user_storage_test/moon.yml index d5794e5d8a233..94950dff066c7 100644 --- a/src/core/packages/user-storage/test/plugins/user_storage_test/moon.yml +++ b/src/platform/test/user_storage/plugins/user_storage_test/moon.yml @@ -15,7 +15,7 @@ project: description: Moon project for @kbn/user-storage-test-plugin channel: '' owner: '@elastic/appex-sharedux' - sourceRoot: src/core/packages/user-storage/test/plugins/user_storage_test + sourceRoot: src/platform/test/user_storage/plugins/user_storage_test dependsOn: - '@kbn/core' - '@kbn/core-user-storage-browser' diff --git a/src/core/packages/user-storage/test/plugins/user_storage_test/package.json b/src/platform/test/user_storage/plugins/user_storage_test/package.json similarity index 54% rename from src/core/packages/user-storage/test/plugins/user_storage_test/package.json rename to src/platform/test/user_storage/plugins/user_storage_test/package.json index e2f0d02f61087..cb683f2aa9c22 100644 --- a/src/core/packages/user-storage/test/plugins/user_storage_test/package.json +++ b/src/platform/test/user_storage/plugins/user_storage_test/package.json @@ -1,14 +1,14 @@ { "name": "@kbn/user-storage-test-plugin", "version": "1.0.0", - "main": "target/test/plugins/user_storage_test", + "main": "target/test/user_storage/plugins/user_storage_test", "kibana": { "version": "kibana", "templateVersion": "1.0.0" }, "license": "Elastic License 2.0 OR AGPL-3.0-only OR SSPL-1.0", "scripts": { - "kbn": "node ../../../../../../../../scripts/kbn.js", - "build": "rm -rf './target' && ../../../../../../../../node_modules/.bin/tsc" + "kbn": "node ../../../../../../scripts/kbn.js", + "build": "rm -rf './target' && tsc" } } diff --git a/src/core/packages/user-storage/test/plugins/user_storage_test/public/application.tsx b/src/platform/test/user_storage/plugins/user_storage_test/public/application.tsx similarity index 100% rename from src/core/packages/user-storage/test/plugins/user_storage_test/public/application.tsx rename to src/platform/test/user_storage/plugins/user_storage_test/public/application.tsx diff --git a/src/core/packages/user-storage/test/plugins/user_storage_test/public/components/app.tsx b/src/platform/test/user_storage/plugins/user_storage_test/public/components/app.tsx similarity index 100% rename from src/core/packages/user-storage/test/plugins/user_storage_test/public/components/app.tsx rename to src/platform/test/user_storage/plugins/user_storage_test/public/components/app.tsx diff --git a/src/core/packages/user-storage/test/plugins/user_storage_test/public/index.ts b/src/platform/test/user_storage/plugins/user_storage_test/public/index.ts similarity index 100% rename from src/core/packages/user-storage/test/plugins/user_storage_test/public/index.ts rename to src/platform/test/user_storage/plugins/user_storage_test/public/index.ts diff --git a/src/core/packages/user-storage/test/plugins/user_storage_test/public/plugin.ts b/src/platform/test/user_storage/plugins/user_storage_test/public/plugin.ts similarity index 100% rename from src/core/packages/user-storage/test/plugins/user_storage_test/public/plugin.ts rename to src/platform/test/user_storage/plugins/user_storage_test/public/plugin.ts diff --git a/src/core/packages/user-storage/test/plugins/user_storage_test/server/index.ts b/src/platform/test/user_storage/plugins/user_storage_test/server/index.ts similarity index 100% rename from src/core/packages/user-storage/test/plugins/user_storage_test/server/index.ts rename to src/platform/test/user_storage/plugins/user_storage_test/server/index.ts diff --git a/src/core/packages/user-storage/test/plugins/user_storage_test/server/plugin.ts b/src/platform/test/user_storage/plugins/user_storage_test/server/plugin.ts similarity index 100% rename from src/core/packages/user-storage/test/plugins/user_storage_test/server/plugin.ts rename to src/platform/test/user_storage/plugins/user_storage_test/server/plugin.ts diff --git a/src/core/packages/user-storage/test/plugins/user_storage_test/tsconfig.json b/src/platform/test/user_storage/plugins/user_storage_test/tsconfig.json similarity index 88% rename from src/core/packages/user-storage/test/plugins/user_storage_test/tsconfig.json rename to src/platform/test/user_storage/plugins/user_storage_test/tsconfig.json index afc276d647e92..2a62744da5c0e 100644 --- a/src/core/packages/user-storage/test/plugins/user_storage_test/tsconfig.json +++ b/src/platform/test/user_storage/plugins/user_storage_test/tsconfig.json @@ -7,7 +7,7 @@ "server/**/*.ts", "public/**/*.ts", "public/**/*.tsx", - "../../../../../../../../typings/**/*", + "../../../../../../typings/**/*", ], "exclude": [ "target/**/*", diff --git a/tsconfig.base.json b/tsconfig.base.json index 8776e01dfee9d..9a8e5148a78c0 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -2648,8 +2648,8 @@ "@kbn/user-profile-examples-plugin/*": ["examples/user_profile_examples/*"], "@kbn/user-profiles-consumer-plugin": ["x-pack/platform/test/security_api_integration/plugins/user_profiles_consumer"], "@kbn/user-profiles-consumer-plugin/*": ["x-pack/platform/test/security_api_integration/plugins/user_profiles_consumer/*"], - "@kbn/user-storage-test-plugin": ["src/core/packages/user-storage/test/plugins/user_storage_test"], - "@kbn/user-storage-test-plugin/*": ["src/core/packages/user-storage/test/plugins/user_storage_test/*"], + "@kbn/user-storage-test-plugin": ["src/platform/test/user_storage/plugins/user_storage_test"], + "@kbn/user-storage-test-plugin/*": ["src/platform/test/user_storage/plugins/user_storage_test/*"], "@kbn/utility-types": ["src/platform/packages/shared/kbn-utility-types"], "@kbn/utility-types/*": ["src/platform/packages/shared/kbn-utility-types/*"], "@kbn/utility-types-jest": ["src/platform/packages/shared/kbn-utility-types-jest"], From e4853f702d3ac274fcbd4d824b02b0e850faadbc Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Mon, 18 May 2026 22:13:48 +0000 Subject: [PATCH 19/41] Changes from node scripts/lint.js --fix --- yarn.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/yarn.lock b/yarn.lock index 70a9aaef1319d..b905bbd8b4095 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9766,7 +9766,7 @@ version "0.0.0" uid "" -"@kbn/user-storage-test-plugin@link:src/core/packages/user-storage/test/plugins/user_storage_test": +"@kbn/user-storage-test-plugin@link:src/platform/test/user_storage/plugins/user_storage_test": version "0.0.0" uid "" From dea9a240571aed85cf0927aa9c4a8a08ad306d28 Mon Sep 17 00:00:00 2001 From: Timothy Sullivan Date: Mon, 18 May 2026 15:20:23 -0700 Subject: [PATCH 20/41] refactor fetch user storage values in server render --- .../server-internal/src/rendering_service.tsx | 37 ++++--------------- 1 file changed, 8 insertions(+), 29 deletions(-) diff --git a/src/core/packages/rendering/server-internal/src/rendering_service.tsx b/src/core/packages/rendering/server-internal/src/rendering_service.tsx index 7510cf0dd2957..12447aecc246c 100644 --- a/src/core/packages/rendering/server-internal/src/rendering_service.tsx +++ b/src/core/packages/rendering/server-internal/src/rendering_service.tsx @@ -9,11 +9,10 @@ import React from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; -import { BehaviorSubject, defer, firstValueFrom, of, map, catchError, take, timeout } from 'rxjs'; +import { BehaviorSubject, firstValueFrom, of, map, catchError, take, timeout } from 'rxjs'; import { i18n as i18nLib } from '@kbn/i18n'; import type { ThemeVersion } from '@kbn/ui-shared-deps-npm'; -import type { Logger } from '@kbn/logging'; import type { CoreContext } from '@kbn/core-base-server-internal'; import type { KibanaRequest, HttpAuth } from '@kbn/core-http-server'; import type { IUiSettingsClient } from '@kbn/core-ui-settings-server'; @@ -73,12 +72,9 @@ export class RenderingService { private readonly themeName$ = new BehaviorSubject(DEFAULT_THEME_NAME); private airgapped: boolean = false; private isCoreRenderingInReactConcurrentMode: boolean = true; - private readonly logger: Logger; // Optional so `render()` is safe to call before `start()` runs. private userStorageStart?: UserStorageServiceStart; - constructor(private readonly coreContext: CoreContext) { - this.logger = coreContext.logger.get('rendering'); - } + constructor(private readonly coreContext: CoreContext) {} public async preboot({ http, @@ -196,12 +192,6 @@ export class RenderingService { const basePath = http.basePath.get(request); const { serverBasePath, publicBaseUrl } = http.basePath; - // 50ms budget inside `fetchUserStorageValues` so a slow ES read never - // blocks first paint. Anonymous pages have no profile_uid, so skip. - const userStorageValuesPromise: Promise> = isAnonymousPage - ? Promise.resolve({}) - : this.fetchUserStorageValues(request); - // Grouping all async HTTP requests to run them concurrently for performance reasons. // Anonymous pages skip user-scoped values and async default values (the latter typically // call ES via `asCurrentUser`, which would 401 on an unauthenticated request). @@ -211,6 +201,7 @@ export class RenderingService { globalSettingsUserValues = {}, userSettingDarkMode, userSettingLocale, + userStorageValues = {}, ] = await Promise.all( isAnonymousPage ? [uiSettings.client?.getRegistered() ?? {}] @@ -222,12 +213,15 @@ export class RenderingService { userSettings?.getUserSettingDarkMode(request), // locale userSettings?.getUserSettingLocale(request), + // user storage + this.fetchUserStorageValues(request), ] as [ ReturnType, Promise>, Promise>, Promise | undefined, - Promise | undefined + Promise | undefined, + Promise> ]) ); @@ -309,8 +303,6 @@ export class RenderingService { const filteredPlugins = filterUiPlugins({ uiPlugins, isAnonymousPage }); const bootstrapScript = isAnonymousPage ? 'bootstrap-anonymous.js' : 'bootstrap.js'; - const userStorageValues = await userStorageValuesPromise; - const useRspack = isRspackModeEnabled(); const uiPublicUrl = `${staticAssetsHrefBase}/ui`; @@ -417,20 +409,7 @@ export class RenderingService { const client = userStorage.asScoped(request); if (!client) return {}; - return firstValueFrom( - defer(() => client.getAll()).pipe( - timeout(50), - catchError((err) => { - // debug, not warn: first-login (no SO yet) is the common case. - this.logger.debug( - `Falling back to default userStorage values for render: ${ - err instanceof Error ? err.message : String(err) - }` - ); - return of>({}); - }) - ) - ); + return client.getAll(); } } From 2307f4618f7b466b4c2472d3b764c0ffba70e897 Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Mon, 18 May 2026 22:28:19 +0000 Subject: [PATCH 21/41] Changes from node scripts/lint_ts_projects --fix --- src/core/packages/rendering/server-internal/tsconfig.json | 1 - 1 file changed, 1 deletion(-) diff --git a/src/core/packages/rendering/server-internal/tsconfig.json b/src/core/packages/rendering/server-internal/tsconfig.json index cdeb9efa41dfb..38ec4bc405f8c 100644 --- a/src/core/packages/rendering/server-internal/tsconfig.json +++ b/src/core/packages/rendering/server-internal/tsconfig.json @@ -50,7 +50,6 @@ "@kbn/core-feature-flags-server", "@kbn/core-user-storage-server", "@kbn/core-user-storage-server-mocks", - "@kbn/logging", "@kbn/repo-info", ], "exclude": [ From c0b620d0f421b03b6ed4c3f706f43c780ca03c97 Mon Sep 17 00:00:00 2001 From: Timothy Sullivan Date: Mon, 18 May 2026 15:30:34 -0700 Subject: [PATCH 22/41] clean up --- .../packages/rendering/server-internal/src/rendering_service.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/core/packages/rendering/server-internal/src/rendering_service.tsx b/src/core/packages/rendering/server-internal/src/rendering_service.tsx index 12447aecc246c..1800cd493e648 100644 --- a/src/core/packages/rendering/server-internal/src/rendering_service.tsx +++ b/src/core/packages/rendering/server-internal/src/rendering_service.tsx @@ -72,7 +72,6 @@ export class RenderingService { private readonly themeName$ = new BehaviorSubject(DEFAULT_THEME_NAME); private airgapped: boolean = false; private isCoreRenderingInReactConcurrentMode: boolean = true; - // Optional so `render()` is safe to call before `start()` runs. private userStorageStart?: UserStorageServiceStart; constructor(private readonly coreContext: CoreContext) {} From 55f07f4923c32fb45220900b195fa60f09acf1b2 Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Mon, 18 May 2026 22:33:26 +0000 Subject: [PATCH 23/41] Changes from node scripts/generate codeowners --- .github/CODEOWNERS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 3c00b291e8786..b3a3e39ca8f83 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -357,7 +357,6 @@ src/core/packages/user-storage/common @elastic/appex-sharedux src/core/packages/user-storage/server @elastic/appex-sharedux src/core/packages/user-storage/server-internal @elastic/appex-sharedux src/core/packages/user-storage/server-mocks @elastic/appex-sharedux -src/core/packages/user-storage/test/plugins/user_storage_test @elastic/appex-sharedux src/core/test-helpers/kbn-server @elastic/kibana-core src/core/test-helpers/model-versions @elastic/kibana-core src/platform/kbn-ui/side-navigation @elastic/appex-sharedux @@ -928,6 +927,7 @@ src/platform/test/plugin_functional/plugins/ui_settings_plugin @elastic/kibana-c src/platform/test/plugin_functional/plugins/usage_collection @elastic/kibana-core src/platform/test/server_integration/plugins/status_plugin_a @elastic/kibana-core src/platform/test/server_integration/plugins/status_plugin_b @elastic/kibana-core +src/platform/test/user_storage/plugins/user_storage_test @elastic/appex-sharedux src/setup_node_env @elastic/kibana-operations x-pack/examples/alerting_example @elastic/response-ops x-pack/examples/embedded_lens_example @elastic/kibana-visualizations From 74d55357e191a3c62603c6b2162740ff912ce264 Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Mon, 18 May 2026 22:33:35 +0000 Subject: [PATCH 24/41] Changes from node scripts/regenerate_moon_projects.js --update --- src/core/packages/rendering/server-internal/moon.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/src/core/packages/rendering/server-internal/moon.yml b/src/core/packages/rendering/server-internal/moon.yml index 06f3f8fc07599..52758453f5176 100644 --- a/src/core/packages/rendering/server-internal/moon.yml +++ b/src/core/packages/rendering/server-internal/moon.yml @@ -54,7 +54,6 @@ dependsOn: - '@kbn/core-feature-flags-server' - '@kbn/core-user-storage-server' - '@kbn/core-user-storage-server-mocks' - - '@kbn/logging' - '@kbn/repo-info' tags: - shared-server From 6a47424b47634fe7f73ed41f14d55cf6002f2ac8 Mon Sep 17 00:00:00 2001 From: Timothy Sullivan Date: Mon, 18 May 2026 15:37:26 -0700 Subject: [PATCH 25/41] add type overloads to meet documentation of client.get() --- .../src/user_storage_client.ts | 16 +++++++---- .../user-storage/browser/src/types.ts | 13 +++++---- .../browser/src/use_user_storage.ts | 28 +++++++++++++++---- 3 files changed, 41 insertions(+), 16 deletions(-) diff --git a/src/core/packages/user-storage/browser-internal/src/user_storage_client.ts b/src/core/packages/user-storage/browser-internal/src/user_storage_client.ts index db5d0bbf9ba38..ee14c1b699c11 100644 --- a/src/core/packages/user-storage/browser-internal/src/user_storage_client.ts +++ b/src/core/packages/user-storage/browser-internal/src/user_storage_client.ts @@ -44,17 +44,23 @@ export class UserStorageClient implements IUserStorageClient { }); } - public get(key: string, defaultValue?: T): T { + public get(key: string): T | undefined; + public get(key: string, defaultValue: T): T; + public get(key: string, defaultValue?: T): T | undefined { const cached = this.cache[key]; - return (cached !== undefined ? cached : defaultValue) as T; + return cached !== undefined ? (cached as T) : defaultValue; } - public get$(key: string, defaultValue?: T): Observable { + public get$(key: string): Observable; + public get$(key: string, defaultValue: T): Observable; + public get$(key: string, defaultValue?: T): Observable { + const getCurrent = () => + defaultValue !== undefined ? this.get(key, defaultValue) : this.get(key); return concat( - defer(() => of(this.get(key, defaultValue))), + defer(() => of(getCurrent())), this.update$.pipe( filter((u) => u.key === key), - map(() => this.get(key, defaultValue)) + map(() => getCurrent()) ) ); } diff --git a/src/core/packages/user-storage/browser/src/types.ts b/src/core/packages/user-storage/browser/src/types.ts index 719e025125007..fa9209ffff009 100644 --- a/src/core/packages/user-storage/browser/src/types.ts +++ b/src/core/packages/user-storage/browser/src/types.ts @@ -32,16 +32,19 @@ export interface UserStorageUpdate { */ export interface IUserStorageClient { /** - * Synchronous read from the local cache. Returns `defaultValue` (or - * `undefined` if not provided) when no cached value exists for the key. + * Synchronous read from the local cache. Returns `undefined` when no cached + * value exists for the key and no `defaultValue` is provided. */ - get(key: string, defaultValue?: T): T; + get(key: string): T | undefined; + get(key: string, defaultValue: T): T; /** * Observable that emits the current cached value followed by every future - * value seen for the given key. Suitable for React subscriptions. + * value seen for the given key. Emits `undefined` when no cached value + * exists and no `defaultValue` is provided. Suitable for React subscriptions. */ - get$(key: string, defaultValue?: T): Observable; + get$(key: string): Observable; + get$(key: string, defaultValue: T): Observable; /** * Returns a clone of every cached key/value pair. diff --git a/src/core/packages/user-storage/browser/src/use_user_storage.ts b/src/core/packages/user-storage/browser/src/use_user_storage.ts index 0ea1d06e04bbb..dd853472d100c 100644 --- a/src/core/packages/user-storage/browser/src/use_user_storage.ts +++ b/src/core/packages/user-storage/browser/src/use_user_storage.ts @@ -9,6 +9,7 @@ import { useCallback, useContext, useMemo } from 'react'; import useObservable from 'react-use/lib/useObservable'; +import type { Observable } from 'rxjs'; import type { IUserStorageClient } from './types'; import { UserStorageContext } from './user_storage_context'; @@ -37,6 +38,10 @@ export type UserStorageSetter = (newValue: T) => Promise; * tuple. The value reflects the synchronous cache and re-renders on change. * The setter persists via HTTP and updates the cache on success. * + * When called without a `defaultValue` the first element of the tuple is + * `T | undefined` — it is `undefined` when the key has no cached value. + * When called with a `defaultValue` it is always `T`. + * * @example * ```tsx * const [layout, setLayout] = useUserStorage( @@ -47,18 +52,29 @@ export type UserStorageSetter = (newValue: T) => Promise; * * @public */ -export const useUserStorage = ( +export function useUserStorage(key: string): [T | undefined, UserStorageSetter]; +export function useUserStorage( + key: string, + defaultValue: T +): [T, UserStorageSetter]; +export function useUserStorage( key: string, defaultValue?: T -): [T, UserStorageSetter] => { +): [T | undefined, UserStorageSetter] { const client = useUserStorageClient(); - const observable$ = useMemo(() => client.get$(key, defaultValue), [client, key, defaultValue]); - const value = useObservable(observable$, client.get(key, defaultValue as T)); + const observable$: Observable = useMemo( + () => (defaultValue !== undefined ? client.get$(key, defaultValue) : client.get$(key)), + [client, key, defaultValue] + ); + const value = useObservable( + observable$, + defaultValue !== undefined ? client.get(key, defaultValue) : client.get(key) + ); const set = useCallback>( (newValue) => client.set(key, newValue), [client, key] ); - return [value as T, set]; -}; + return [value, set]; +} From 06fe6b999760b62fe0f5002e4d1b849d98130c18 Mon Sep 17 00:00:00 2001 From: Timothy Sullivan Date: Mon, 18 May 2026 15:58:11 -0700 Subject: [PATCH 26/41] add `type: set | remove` to the update observable interface --- .../src/user_storage_client.test.ts | 13 +++++++------ .../browser-internal/src/user_storage_client.ts | 4 ++-- src/core/packages/user-storage/browser/src/types.ts | 13 ++++++++----- .../browser/src/use_user_storage.test.tsx | 3 ++- 4 files changed, 19 insertions(+), 14 deletions(-) diff --git a/src/core/packages/user-storage/browser-internal/src/user_storage_client.test.ts b/src/core/packages/user-storage/browser-internal/src/user_storage_client.test.ts index 83217d2252cc1..33743175ce261 100644 --- a/src/core/packages/user-storage/browser-internal/src/user_storage_client.test.ts +++ b/src/core/packages/user-storage/browser-internal/src/user_storage_client.test.ts @@ -85,7 +85,12 @@ describe('UserStorageClient', () => { await client.set('key', 'new'); expect(client.get('key')).toBe('new'); - await expect(updates).resolves.toEqual({ key: 'key', newValue: 'new', oldValue: 'old' }); + await expect(updates).resolves.toEqual({ + type: 'set', + key: 'key', + newValue: 'new', + oldValue: 'old', + }); }); it('does not mutate cache or emit when the HTTP call fails, and rejects', async () => { @@ -110,11 +115,7 @@ describe('UserStorageClient', () => { await client.remove('key'); expect(client.get('key')).toBeUndefined(); - await expect(updates).resolves.toEqual({ - key: 'key', - newValue: undefined, - oldValue: 'old', - }); + await expect(updates).resolves.toEqual({ type: 'remove', key: 'key', oldValue: 'old' }); }); it('rejects and emits on errors$ when the HTTP call fails', async () => { diff --git a/src/core/packages/user-storage/browser-internal/src/user_storage_client.ts b/src/core/packages/user-storage/browser-internal/src/user_storage_client.ts index ee14c1b699c11..37aa1cecc72e7 100644 --- a/src/core/packages/user-storage/browser-internal/src/user_storage_client.ts +++ b/src/core/packages/user-storage/browser-internal/src/user_storage_client.ts @@ -80,7 +80,7 @@ export class UserStorageClient implements IUserStorageClient { const oldValue = this.cache[key]; this.cache[key] = value; - this.update$.next({ key, newValue: value, oldValue }); + this.update$.next({ type: 'set', key, newValue: value, oldValue }); } public async remove(key: string): Promise { @@ -94,7 +94,7 @@ export class UserStorageClient implements IUserStorageClient { const oldValue = this.cache[key]; delete this.cache[key]; - this.update$.next({ key, newValue: undefined, oldValue }); + this.update$.next({ type: 'remove', key, oldValue }); } public getUpdate$(): Observable { diff --git a/src/core/packages/user-storage/browser/src/types.ts b/src/core/packages/user-storage/browser/src/types.ts index fa9209ffff009..41a76bd7c71a4 100644 --- a/src/core/packages/user-storage/browser/src/types.ts +++ b/src/core/packages/user-storage/browser/src/types.ts @@ -12,13 +12,16 @@ import type { Observable } from 'rxjs'; /** * An update emission published when a stored value changes. * + * Use the `type` discriminant to distinguish a value write (`'set'`) from a + * user-override removal (`'remove'`). The `'remove'` variant has no `newValue` + * because the effective value reverts to the registered default — callers + * should read the post-removal state via `get()` if needed. + * * @public */ -export interface UserStorageUpdate { - key: string; - newValue: T; - oldValue: T | undefined; -} +export type UserStorageUpdate = + | { type: 'set'; key: string; newValue: T; oldValue: T | undefined } + | { type: 'remove'; key: string; oldValue: T | undefined }; /** * Browser-side user storage client. Returns synchronously from an in-memory diff --git a/src/core/packages/user-storage/browser/src/use_user_storage.test.tsx b/src/core/packages/user-storage/browser/src/use_user_storage.test.tsx index 4893caab9773c..ebbe281fc1d21 100644 --- a/src/core/packages/user-storage/browser/src/use_user_storage.test.tsx +++ b/src/core/packages/user-storage/browser/src/use_user_storage.test.tsx @@ -35,7 +35,8 @@ const buildClient = (initial: Record = {}): IUserStorageClient delete cache[key]; subject$.next({ ...cache }); }) as IUserStorageClient['remove'], - getUpdate$: () => new BehaviorSubject({ key: '', newValue: undefined, oldValue: undefined }), + getUpdate$: () => + new BehaviorSubject({ type: 'remove' as const, key: '', oldValue: undefined }), getUpdateErrors$: () => new BehaviorSubject(new Error('noop')), }; return client; From 973c11672cbacf00317dd0a5e525aeb4da3f1660 Mon Sep 17 00:00:00 2001 From: Timothy Sullivan Date: Mon, 18 May 2026 16:03:07 -0700 Subject: [PATCH 27/41] fix tests asserting the old timeout+catchError behavior --- .../src/rendering_service.test.ts | 25 ++----------------- 1 file changed, 2 insertions(+), 23 deletions(-) diff --git a/src/core/packages/rendering/server-internal/src/rendering_service.test.ts b/src/core/packages/rendering/server-internal/src/rendering_service.test.ts index f94b65add3502..1d9bcdf534735 100644 --- a/src/core/packages/rendering/server-internal/src/rendering_service.test.ts +++ b/src/core/packages/rendering/server-internal/src/rendering_service.test.ts @@ -815,35 +815,14 @@ describe('RenderingService', () => { expect(await renderAndReadUserStorage(content)).toEqual({ values: {} }); }); - it('falls back to empty values when getAll() rejects', async () => { + it('rejects when getAll() rejects', async () => { const { render } = await service.setup(mockRenderingSetupDeps); const getAll = jest.fn().mockRejectedValue(new Error('ES exploded')); const asScoped = jest.fn().mockReturnValue({ getAll }); service.start({ ...mockRenderingStartDeps, userStorage: { asScoped } }); - const content = await render(createKibanaRequest(), buildUiSettings()); - - expect(await renderAndReadUserStorage(content)).toEqual({ values: {} }); - }); - - it('falls back to empty values when getAll() exceeds the timeout', async () => { - const { render } = await service.setup(mockRenderingSetupDeps); - - // resolves after the 50ms render-time budget; the rendering path - // should not wait for it. - const getAll = jest - .fn() - .mockImplementation(() => new Promise((resolve) => setTimeout(() => resolve({}), 5_000))); - const asScoped = jest.fn().mockReturnValue({ getAll }); - service.start({ ...mockRenderingStartDeps, userStorage: { asScoped } }); - - const renderPromise = render(createKibanaRequest(), buildUiSettings()); - // advance past the 50ms timeout so RxJS' `timeout` operator fires. - await jest.advanceTimersByTimeAsync(60); - const content = await renderPromise; - - expect(await renderAndReadUserStorage(content)).toEqual({ values: {} }); + await expect(render(createKibanaRequest(), buildUiSettings())).rejects.toThrow('ES exploded'); }); }); From 69371cb1a2b6f59d77a4e106532d703e280032fb Mon Sep 17 00:00:00 2001 From: Timothy Sullivan Date: Mon, 18 May 2026 16:20:35 -0700 Subject: [PATCH 28/41] remove getAll method from browser client --- .../browser-internal/src/user_storage_api.test.ts | 8 -------- .../browser-internal/src/user_storage_api.ts | 4 ---- .../browser-internal/src/user_storage_client.test.ts | 12 +----------- .../browser-internal/src/user_storage_client.ts | 4 ---- .../user-storage/browser-mocks/src/client.mock.ts | 1 - src/core/packages/user-storage/browser/src/types.ts | 5 ----- .../browser/src/use_user_storage.test.tsx | 1 - 7 files changed, 1 insertion(+), 34 deletions(-) diff --git a/src/core/packages/user-storage/browser-internal/src/user_storage_api.test.ts b/src/core/packages/user-storage/browser-internal/src/user_storage_api.test.ts index e0fdf044c91c0..29fcf5df65215 100644 --- a/src/core/packages/user-storage/browser-internal/src/user_storage_api.test.ts +++ b/src/core/packages/user-storage/browser-internal/src/user_storage_api.test.ts @@ -15,19 +15,11 @@ describe('UserStorageApi', () => { let api: UserStorageApi; beforeEach(() => { - http.get.mockReset(); http.put.mockReset(); http.delete.mockReset(); api = new UserStorageApi(http); }); - it('GETs /internal/user_storage for getAll', async () => { - http.get.mockResolvedValue({ foo: 1 }); - - await expect(api.getAll()).resolves.toEqual({ foo: 1 }); - expect(http.get).toHaveBeenCalledWith('/internal/user_storage'); - }); - it('PUTs /internal/user_storage/{key} with a value-wrapped body', async () => { http.put.mockResolvedValue(undefined); diff --git a/src/core/packages/user-storage/browser-internal/src/user_storage_api.ts b/src/core/packages/user-storage/browser-internal/src/user_storage_api.ts index e971b9b8589ac..7b2465b562c30 100644 --- a/src/core/packages/user-storage/browser-internal/src/user_storage_api.ts +++ b/src/core/packages/user-storage/browser-internal/src/user_storage_api.ts @@ -20,10 +20,6 @@ const BASE_PATH = '/internal/user_storage'; export class UserStorageApi { constructor(private readonly http: InternalHttpSetup) {} - public async getAll(): Promise> { - return this.http.get>(BASE_PATH); - } - public async set(key: string, value: unknown): Promise { await this.http.put(`${BASE_PATH}/${encodeURIComponent(key)}`, { body: JSON.stringify({ value }), diff --git a/src/core/packages/user-storage/browser-internal/src/user_storage_client.test.ts b/src/core/packages/user-storage/browser-internal/src/user_storage_client.test.ts index 33743175ce261..b3388e79caf65 100644 --- a/src/core/packages/user-storage/browser-internal/src/user_storage_client.test.ts +++ b/src/core/packages/user-storage/browser-internal/src/user_storage_client.test.ts @@ -26,13 +26,12 @@ const buildClient = (initialValues: Record = {}) => { }; describe('UserStorageClient', () => { - describe('get / getAll', () => { + describe('get', () => { it('returns cached values seeded from initialValues', () => { const { client } = buildClient({ a: 1, b: 'two' }); expect(client.get('a')).toBe(1); expect(client.get('b')).toBe('two'); - expect(client.getAll()).toEqual({ a: 1, b: 'two' }); }); it('returns the defaultValue when the key is not cached', () => { @@ -40,15 +39,6 @@ describe('UserStorageClient', () => { expect(client.get('missing', 'fallback')).toBe('fallback'); }); - - it('clones getAll output to prevent external mutation of cache', () => { - const { client } = buildClient({ list: [1, 2] }); - - const all = client.getAll() as { list: number[] }; - all.list.push(3); - - expect(client.get('list')).toEqual([1, 2]); - }); }); describe('get$', () => { diff --git a/src/core/packages/user-storage/browser-internal/src/user_storage_client.ts b/src/core/packages/user-storage/browser-internal/src/user_storage_client.ts index 37aa1cecc72e7..9abaf41bb1c97 100644 --- a/src/core/packages/user-storage/browser-internal/src/user_storage_client.ts +++ b/src/core/packages/user-storage/browser-internal/src/user_storage_client.ts @@ -65,10 +65,6 @@ export class UserStorageClient implements IUserStorageClient { ); } - public getAll(): Readonly> { - return cloneDeep(this.cache); - } - public async set(key: string, value: T): Promise { try { await this.api.set(key, value); diff --git a/src/core/packages/user-storage/browser-mocks/src/client.mock.ts b/src/core/packages/user-storage/browser-mocks/src/client.mock.ts index d7dba7d3db4be..dd9e5f1f8c113 100644 --- a/src/core/packages/user-storage/browser-mocks/src/client.mock.ts +++ b/src/core/packages/user-storage/browser-mocks/src/client.mock.ts @@ -15,7 +15,6 @@ export const clientMock = (): jest.Mocked => lazyObject({ get: jest.fn(), get$: jest.fn().mockReturnValue(new Subject()), - getAll: jest.fn().mockReturnValue({}), set: jest.fn().mockResolvedValue(undefined), remove: jest.fn().mockResolvedValue(undefined), getUpdate$: jest.fn().mockReturnValue(new Subject()), diff --git a/src/core/packages/user-storage/browser/src/types.ts b/src/core/packages/user-storage/browser/src/types.ts index 41a76bd7c71a4..bc2b5896679ec 100644 --- a/src/core/packages/user-storage/browser/src/types.ts +++ b/src/core/packages/user-storage/browser/src/types.ts @@ -49,11 +49,6 @@ export interface IUserStorageClient { get$(key: string): Observable; get$(key: string, defaultValue: T): Observable; - /** - * Returns a clone of every cached key/value pair. - */ - getAll(): Readonly>; - /** * Persists a new value via `PUT /internal/user_storage/{key}`. On success * the local cache is updated and subscribers to `get$` / `getUpdate$` are diff --git a/src/core/packages/user-storage/browser/src/use_user_storage.test.tsx b/src/core/packages/user-storage/browser/src/use_user_storage.test.tsx index ebbe281fc1d21..bce234bb6b02f 100644 --- a/src/core/packages/user-storage/browser/src/use_user_storage.test.tsx +++ b/src/core/packages/user-storage/browser/src/use_user_storage.test.tsx @@ -26,7 +26,6 @@ const buildClient = (initial: Record = {}): IUserStorageClient cache[key] !== undefined ? cache[key] : defaultValue ).asObservable(); }) as IUserStorageClient['get$'], - getAll: () => ({ ...cache }), set: jest.fn(async (key: string, value: unknown) => { cache[key] = value; subject$.next({ ...cache }); From cac10e19f5b9e16519540481951182ba755e1df2 Mon Sep 17 00:00:00 2001 From: Timothy Sullivan Date: Mon, 18 May 2026 16:51:17 -0700 Subject: [PATCH 29/41] browser client mock: delete createMock/create --- .../browser-mocks/src/service_contract.mock.ts | 17 ----------------- .../src/user_storage_service.mock.ts | 16 ++-------------- 2 files changed, 2 insertions(+), 31 deletions(-) delete mode 100644 src/core/packages/user-storage/browser-mocks/src/service_contract.mock.ts diff --git a/src/core/packages/user-storage/browser-mocks/src/service_contract.mock.ts b/src/core/packages/user-storage/browser-mocks/src/service_contract.mock.ts deleted file mode 100644 index 570e87ade37db..0000000000000 --- a/src/core/packages/user-storage/browser-mocks/src/service_contract.mock.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import { lazyObject } from '@kbn/lazy-object'; - -export const serviceContractMock = (): jest.Mocked => - lazyObject({ - setup: jest.fn(), - start: jest.fn(), - stop: jest.fn(), - }); diff --git a/src/core/packages/user-storage/browser-mocks/src/user_storage_service.mock.ts b/src/core/packages/user-storage/browser-mocks/src/user_storage_service.mock.ts index fd7d905b7b0c2..ea837fc778914 100644 --- a/src/core/packages/user-storage/browser-mocks/src/user_storage_service.mock.ts +++ b/src/core/packages/user-storage/browser-mocks/src/user_storage_service.mock.ts @@ -8,20 +8,8 @@ */ import { clientMock } from './client.mock'; -import { serviceContractMock } from './service_contract.mock'; - -const createSetupContract = () => clientMock(); -const createStartContract = () => clientMock(); - -const createMock = () => { - const mocked = serviceContractMock(); - mocked.setup.mockReturnValue(createSetupContract()); - mocked.start.mockReturnValue(createStartContract()); - return mocked; -}; export const userStorageServiceMock = { - create: createMock, - createSetupContract, - createStartContract, + createSetupContract: () => clientMock(), + createStartContract: () => clientMock(), }; From bc92fc1f27a5be44cfab4b226a202120a8eb2499 Mon Sep 17 00:00:00 2001 From: Timothy Sullivan Date: Mon, 18 May 2026 17:59:10 -0700 Subject: [PATCH 30/41] add serverInject option in UserStorageDefinition --- .../src/rendering_service.test.ts | 16 ++-- .../server-internal/src/rendering_service.tsx | 2 +- src/core/packages/user-storage/README.mdx | 43 +++++---- .../src/user_storage_api.test.ts | 10 ++ .../browser-internal/src/user_storage_api.ts | 7 ++ .../src/user_storage_client.test.ts | 79 ++++++++++++++-- .../src/user_storage_client.ts | 92 +++++++++++++++--- .../src/user_storage_service.test.ts | 2 +- .../browser-mocks/src/client.mock.ts | 2 +- .../user-storage/browser/src/types.ts | 11 ++- .../browser/src/use_user_storage.test.tsx | 2 +- .../packages/user-storage/common/src/types.ts | 22 ++++- .../server-internal/src/routes/index.ts | 21 ++++- .../src/user_storage_client.test.ts | 93 ++++++++++++++----- .../src/user_storage_client.ts | 11 ++- 15 files changed, 320 insertions(+), 93 deletions(-) diff --git a/src/core/packages/rendering/server-internal/src/rendering_service.test.ts b/src/core/packages/rendering/server-internal/src/rendering_service.test.ts index 1d9bcdf534735..81b3409f4afbf 100644 --- a/src/core/packages/rendering/server-internal/src/rendering_service.test.ts +++ b/src/core/packages/rendering/server-internal/src/rendering_service.test.ts @@ -773,17 +773,19 @@ describe('RenderingService', () => { globalClient: uiSettingsServiceMock.createClient(), }); - it('injects values returned by userStorage.asScoped().getAll()', async () => { + it('injects values returned by userStorage.asScoped().getForInjection()', async () => { const { render } = await service.setup(mockRenderingSetupDeps); - const getAll = jest.fn().mockResolvedValue({ 'navigation:layout': { hidden: ['discover'] } }); - const asScoped = jest.fn().mockReturnValue({ getAll }); + const getForInjection = jest + .fn() + .mockResolvedValue({ 'navigation:layout': { hidden: ['discover'] } }); + const asScoped = jest.fn().mockReturnValue({ getForInjection }); service.start({ ...mockRenderingStartDeps, userStorage: { asScoped } }); const content = await render(createKibanaRequest(), buildUiSettings()); expect(asScoped).toHaveBeenCalledTimes(1); - expect(getAll).toHaveBeenCalledTimes(1); + expect(getForInjection).toHaveBeenCalledTimes(1); expect(await renderAndReadUserStorage(content)).toEqual({ values: { 'navigation:layout': { hidden: ['discover'] } }, }); @@ -815,11 +817,11 @@ describe('RenderingService', () => { expect(await renderAndReadUserStorage(content)).toEqual({ values: {} }); }); - it('rejects when getAll() rejects', async () => { + it('rejects when getForInjection() rejects', async () => { const { render } = await service.setup(mockRenderingSetupDeps); - const getAll = jest.fn().mockRejectedValue(new Error('ES exploded')); - const asScoped = jest.fn().mockReturnValue({ getAll }); + const getForInjection = jest.fn().mockRejectedValue(new Error('ES exploded')); + const asScoped = jest.fn().mockReturnValue({ getForInjection }); service.start({ ...mockRenderingStartDeps, userStorage: { asScoped } }); await expect(render(createKibanaRequest(), buildUiSettings())).rejects.toThrow('ES exploded'); diff --git a/src/core/packages/rendering/server-internal/src/rendering_service.tsx b/src/core/packages/rendering/server-internal/src/rendering_service.tsx index 1800cd493e648..77944b336488e 100644 --- a/src/core/packages/rendering/server-internal/src/rendering_service.tsx +++ b/src/core/packages/rendering/server-internal/src/rendering_service.tsx @@ -408,7 +408,7 @@ export class RenderingService { const client = userStorage.asScoped(request); if (!client) return {}; - return client.getAll(); + return client.getForInjection(); } } diff --git a/src/core/packages/user-storage/README.mdx b/src/core/packages/user-storage/README.mdx index dc4340860562e..b8ae166a9b6d9 100644 --- a/src/core/packages/user-storage/README.mdx +++ b/src/core/packages/user-storage/README.mdx @@ -64,11 +64,13 @@ The Zod schema runs: 1. At registration, against the `defaultValue` (catches drift between the default and the schema at boot). 2. At every `set`, against the incoming value (rejects invalid writes with `400`). -3. At every `get` / `getAll`, against the stored value. If the stored value fails to parse — usually because the schema has been narrowed since the value was written — the registered default is returned and a warning is logged. This means you can safely tighten a schema without writing a migration, but consumers should treat the resulting value reset as a possible UX outcome. +3. At every `get`, against the stored value. If the stored value fails to parse — usually because the schema has been narrowed since the value was written — the registered default is returned and a warning is logged. This means you can safely tighten a schema without writing a migration, but consumers should treat the resulting value reset as a possible UX outcome. ### Server-rendered injection -On every page render, Core calls `userStorage.asScoped(request).getAll()` and inlines the merged map into `` under `userStorage.values`. The browser client uses this map to seed its synchronous cache before the first React render, so consumers can read the user's value during initial paint without a fetch. The fetch is bounded by a 50 ms timeout and falls back to `{}` (everything reads as default) on slow ES or network errors — User Storage will never block first paint. +By default, browser-side reads are **lazy**: the cache starts empty for a given key and the first `get(key)` / `get$(key)` call fires a `GET /internal/user_storage/{key}` request to hydrate it. While the request is in-flight `get(key)` returns `undefined` (or the provided `defaultValue`) and `get$(key)` emits `undefined` then emits again once the value arrives. + +For keys on the critical rendering path, opt in to **eager injection** by setting `serverInject: true` in the key's `UserStorageDefinition`. Core then calls `getForInjection()` during server-side rendering and embeds only the opted-in keys into `` under `userStorage.values`, so those values are available synchronously before the first React render without any in-browser fetch. ## Registering keys (server) @@ -88,11 +90,13 @@ export class MyPlugin implements Plugin { }), defaultValue: { hidden: [], order: [] }, scope: 'space', + serverInject: true, // embed in HTML at first paint; needed on the critical render path }, 'myPlugin:tour-dismissed': { schema: z.boolean(), defaultValue: false, scope: 'global', + // serverInject omitted — lazy-loaded on first access }, }); } @@ -114,7 +118,6 @@ if (!client) { } const layout = await client.get('myPlugin:nav-layout'); -const all = await client.getAll(); // every registered key, merged with defaults await client.set('myPlugin:nav-layout', { hidden: ['discover'], order: [] }); await client.remove('myPlugin:tour-dismissed'); // resets the key to its default @@ -126,11 +129,11 @@ Server reads always return the resolved value (user override or registered defau Three internal routes are exposed for the browser client; consumers do not normally call them directly: -| Method | Path | Body | Returns | -|--------|---------------------------------------|-------------------|-------------------------------------------| -| `GET` | `/internal/user_storage` | — | Every registered key merged with defaults | -| `PUT` | `/internal/user_storage/{key}` | `{ value }` | `200` on success, `400` on validation | -| `DELETE` | `/internal/user_storage/{key}` | — | `200` on success | +| Method | Path | Body | Returns | +|--------|---------------------------------------|-------------------|------------------------------------------------------| +| `GET` | `/internal/user_storage/{key}` | — | `{ value }` — the stored value or registered default | +| `PUT` | `/internal/user_storage/{key}` | `{ value }` | `200` on success, `400` on validation | +| `DELETE` | `/internal/user_storage/{key}` | — | `200` on success | All three return `403` when the request has no `profile_uid`. @@ -143,26 +146,28 @@ import type { IUserStorageClient } from '@kbn/core-user-storage-browser'; // somewhere with access to core: const layout = core.userStorage.get('myPlugin:nav-layout', defaultLayout); -const all = core.userStorage.getAll(); await core.userStorage.set('myPlugin:nav-layout', nextLayout); await core.userStorage.remove('myPlugin:tour-dismissed'); ``` -Pass a `defaultValue` to `get(key, default)` so that consumers of the value never see `undefined` if the key was not in the injected metadata snapshot (e.g. during anonymous-page rendering). Server-side defaults are the source of truth, but the browser client does not currently know about them — it only knows about cached values. +Pass a `defaultValue` to `get(key, default)` so that consumers never see `undefined`. For keys with `serverInject: true` the cache is pre-populated at first paint so `get` is truly synchronous. For lazy-loaded keys, the first `get` triggers a background fetch and returns `undefined` (or `defaultValue`) until the response arrives. -The client also exposes RxJS observables for live updates and write errors: +The client also exposes RxJS observables for live updates and HTTP errors: ```typescript core.userStorage.get$('myPlugin:nav-layout').subscribe((layout) => { - // emits the cached value on subscribe and again on every successful write to this key + // emits the cached value (or undefined) on subscribe, again when a lazy fetch lands, + // and again on every successful set/remove for this key }); -core.userStorage.getUpdate$().subscribe(({ key, newValue, oldValue }) => { - // fires on every successful write across all keys +core.userStorage.getUpdate$().subscribe((update) => { + // fires on every successful set/remove across all keys (not on lazy fetches) + if (update.type === 'set') console.log(update.key, update.newValue); + if (update.type === 'remove') console.log(update.key, 'removed'); }); -core.userStorage.getUpdateErrors$().subscribe((err) => { - // fires when a set/remove HTTP call fails after the call returned to the caller +core.userStorage.getHttpError$().subscribe((err) => { + // fires when a set/remove/lazy-fetch HTTP call fails }); ``` @@ -199,9 +204,9 @@ const NavLayoutEditor = () => { }; ``` -`useUserStorage(key, defaultValue?)` returns `[value, setter]`. The value reflects the synchronous cache and re-renders on change. The setter persists via HTTP and refreshes the cache on success. If the HTTP write fails, the cache is unchanged, the returned promise rejects, and the error is published to `getUpdateErrors$`. +`useUserStorage(key, defaultValue?)` returns `[value, setter]`. The value reflects the synchronous cache and re-renders on change. The setter persists via HTTP and refreshes the cache on success. If the HTTP write fails, the cache is unchanged, the returned promise rejects, and the error is published to `getHttpError$`. -For the less common operations — `remove`, `getUpdate$`, `getUpdateErrors$`, multi-key reads — reach for the underlying client: +For the less common operations — `remove`, `getUpdate$`, `getHttpError$` — reach for the underlying client: ```tsx const client = useUserStorageClient(); @@ -234,7 +239,7 @@ import { userStorageServiceMock } from '@kbn/core-user-storage-server-mocks'; const userStorage = userStorageServiceMock.createStartContract(); userStorage.asScoped.mockReturnValue({ get: jest.fn().mockResolvedValue({ hidden: ['discover'], order: [] }), - getAll: jest.fn().mockResolvedValue({}), + getForInjection: jest.fn().mockResolvedValue({}), set: jest.fn().mockResolvedValue(undefined), remove: jest.fn().mockResolvedValue(undefined), }); diff --git a/src/core/packages/user-storage/browser-internal/src/user_storage_api.test.ts b/src/core/packages/user-storage/browser-internal/src/user_storage_api.test.ts index 29fcf5df65215..eeb18ab13b618 100644 --- a/src/core/packages/user-storage/browser-internal/src/user_storage_api.test.ts +++ b/src/core/packages/user-storage/browser-internal/src/user_storage_api.test.ts @@ -15,11 +15,21 @@ describe('UserStorageApi', () => { let api: UserStorageApi; beforeEach(() => { + http.get.mockReset(); http.put.mockReset(); http.delete.mockReset(); api = new UserStorageApi(http); }); + it('GETs /internal/user_storage/{key} and unwraps the value field', async () => { + http.get.mockResolvedValue({ value: { hidden: ['discover'] } }); + + const result = await api.get('navigation:layout'); + + expect(http.get).toHaveBeenCalledWith('/internal/user_storage/navigation%3Alayout'); + expect(result).toEqual({ hidden: ['discover'] }); + }); + it('PUTs /internal/user_storage/{key} with a value-wrapped body', async () => { http.put.mockResolvedValue(undefined); diff --git a/src/core/packages/user-storage/browser-internal/src/user_storage_api.ts b/src/core/packages/user-storage/browser-internal/src/user_storage_api.ts index 7b2465b562c30..25c462d21ae1b 100644 --- a/src/core/packages/user-storage/browser-internal/src/user_storage_api.ts +++ b/src/core/packages/user-storage/browser-internal/src/user_storage_api.ts @@ -20,6 +20,13 @@ const BASE_PATH = '/internal/user_storage'; export class UserStorageApi { constructor(private readonly http: InternalHttpSetup) {} + public async get(key: string): Promise { + const response = await this.http.get<{ value: unknown }>( + `${BASE_PATH}/${encodeURIComponent(key)}` + ); + return response.value; + } + public async set(key: string, value: unknown): Promise { await this.http.put(`${BASE_PATH}/${encodeURIComponent(key)}`, { body: JSON.stringify({ value }), diff --git a/src/core/packages/user-storage/browser-internal/src/user_storage_client.test.ts b/src/core/packages/user-storage/browser-internal/src/user_storage_client.test.ts index b3388e79caf65..d4edba423c20a 100644 --- a/src/core/packages/user-storage/browser-internal/src/user_storage_client.test.ts +++ b/src/core/packages/user-storage/browser-internal/src/user_storage_client.test.ts @@ -13,7 +13,7 @@ import type { UserStorageApi } from './user_storage_api'; const apiMock = (): jest.Mocked => ({ - getAll: jest.fn(), + get: jest.fn().mockReturnValue(new Promise(() => {})), // never resolves by default set: jest.fn(), remove: jest.fn(), } as unknown as jest.Mocked); @@ -39,6 +39,35 @@ describe('UserStorageClient', () => { expect(client.get('missing', 'fallback')).toBe('fallback'); }); + + it('triggers a lazy fetch on first access for an uncached key', () => { + const { client, api } = buildClient({}); + api.get.mockResolvedValue('lazy-value'); + + client.get('uncached'); + + expect(api.get).toHaveBeenCalledWith('uncached'); + }); + + it('does not trigger a second fetch when the key is already cached', () => { + const { client, api } = buildClient({ key: 'present' }); + + client.get('key'); + client.get('key'); + + expect(api.get).not.toHaveBeenCalled(); + }); + + it('does not trigger a second fetch when one is already in flight', () => { + const { client, api } = buildClient({}); + // never resolves — simulates in-flight request + api.get.mockReturnValue(new Promise(() => {})); + + client.get('key'); + client.get('key'); + + expect(api.get).toHaveBeenCalledTimes(1); + }); }); describe('get$', () => { @@ -54,14 +83,48 @@ describe('UserStorageClient', () => { }); it('does not emit for unrelated keys', async () => { - const { client, api } = buildClient({}); + const { client, api } = buildClient({ a: 'initial' }); api.set.mockResolvedValue(undefined); const first = firstValueFrom(client.get$('a')); await client.set('b', 99); // only the initial emission resolves; if `b` had leaked we'd see 99. - await expect(first).resolves.toBeUndefined(); + await expect(first).resolves.toBe('initial'); + }); + + it('emits the lazy-fetched value once the fetch resolves', async () => { + const { client, api } = buildClient({}); + + let resolveFetch!: (v: string) => void; + api.get.mockReturnValue(new Promise((resolve) => (resolveFetch = resolve))); + + const emissions = lastValueFrom(client.get$('key').pipe(take(2), toArray())); + + // Trigger the lazy fetch (first emission is undefined) + resolveFetch('lazy-value'); + // Allow promise microtasks to flush + await Promise.resolve(); + + expect(await emissions).toEqual([undefined, 'lazy-value']); + }); + + it('emits to getHttpError$ and retries on next subscription if fetch fails', async () => { + const { client, api } = buildClient({}); + + api.get.mockRejectedValueOnce(new Error('network-error')); + + const httpError = firstValueFrom(client.getHttpError$()); + + // First get triggers the fetch + client.get('key'); + + await expect(httpError).resolves.toMatchObject({ message: 'network-error' }); + + // After failure the key is removed from fetchInitiated — a new get should re-trigger + api.get.mockResolvedValue('retry-value'); + client.get('key'); + expect(api.get).toHaveBeenCalledTimes(2); }); }); @@ -87,7 +150,7 @@ describe('UserStorageClient', () => { const { client, api } = buildClient({ key: 'old' }); api.set.mockRejectedValue(new Error('boom')); - const errors = firstValueFrom(client.getUpdateErrors$()); + const errors = firstValueFrom(client.getHttpError$()); await expect(client.set('key', 'new')).rejects.toThrow('boom'); expect(client.get('key')).toBe('old'); @@ -108,11 +171,11 @@ describe('UserStorageClient', () => { await expect(updates).resolves.toEqual({ type: 'remove', key: 'key', oldValue: 'old' }); }); - it('rejects and emits on errors$ when the HTTP call fails', async () => { + it('rejects and emits on getHttpError$ when the HTTP call fails', async () => { const { client, api } = buildClient({ key: 'old' }); api.remove.mockRejectedValue(new Error('nope')); - const errors = firstValueFrom(client.getUpdateErrors$()); + const errors = firstValueFrom(client.getHttpError$()); await expect(client.remove('key')).rejects.toThrow('nope'); expect(client.get('key')).toBe('old'); @@ -121,11 +184,11 @@ describe('UserStorageClient', () => { }); describe('done$', () => { - it('completes update$ and updateErrors$ when done$ completes', async () => { + it('completes update$ and getHttpError$ when done$ completes', async () => { const { client, done$ } = buildClient({}); const update$ = client.getUpdate$(); - const errors$ = client.getUpdateErrors$(); + const errors$ = client.getHttpError$(); done$.complete(); diff --git a/src/core/packages/user-storage/browser-internal/src/user_storage_client.ts b/src/core/packages/user-storage/browser-internal/src/user_storage_client.ts index 9abaf41bb1c97..8442bd643ab56 100644 --- a/src/core/packages/user-storage/browser-internal/src/user_storage_client.ts +++ b/src/core/packages/user-storage/browser-internal/src/user_storage_client.ts @@ -8,8 +8,8 @@ */ import { cloneDeep } from 'lodash'; -import { Subject, concat, defer, of, type Observable } from 'rxjs'; -import { filter, map } from 'rxjs'; +import { Observable, Subject, concat, defer, of } from 'rxjs'; +import { filter, map, share } from 'rxjs'; import type { IUserStorageClient, UserStorageUpdate } from '@kbn/core-user-storage-browser'; import type { UserStorageApi } from './user_storage_api'; @@ -22,7 +22,18 @@ export interface UserStorageClientParams { /** * Browser-side {@link IUserStorageClient}: a synchronous in-memory cache - * seeded from server-injected metadata, with HTTP-backed writes. + * seeded from server-injected metadata (for keys with `serverInject: true`), + * with HTTP-backed writes and per-key lazy fetching for non-injected keys. + * + * Lazy fetch behaviour: + * - The first `get(key)` / `get$(key)` call for a key that is absent from + * the cache triggers a fire-and-forget `GET /internal/user_storage/{key}` + * request. Once the response arrives, the cache is populated and `get$` + * subscribers for that key receive the resolved value. + * - Fetch failures are published to `getHttpError$` but do not cause `get$` + * to error or complete. The cache entry remains absent. + * - `getUpdate$()` does **not** emit for lazy-fetch hydrations; only explicit + * `set` / `remove` calls produce update events. * * @internal */ @@ -30,7 +41,11 @@ export class UserStorageClient implements IUserStorageClient { private cache: Record; private readonly api: UserStorageApi; private readonly update$ = new Subject(); - private readonly updateErrors$ = new Subject(); + private readonly httpErrors$ = new Subject(); + /** Emits whenever the cache is hydrated by a lazy fetch. */ + private readonly loaded$ = new Subject<{ key: string; value: unknown }>(); + /** Set of keys for which a lazy fetch has already been initiated. */ + private readonly fetchInitiated = new Set(); constructor({ api, initialValues, done$ }: UserStorageClientParams) { this.api = api; @@ -39,7 +54,8 @@ export class UserStorageClient implements IUserStorageClient { done$.subscribe({ complete: () => { this.update$.complete(); - this.updateErrors$.complete(); + this.httpErrors$.complete(); + this.loaded$.complete(); }, }); } @@ -48,7 +64,9 @@ export class UserStorageClient implements IUserStorageClient { public get(key: string, defaultValue: T): T; public get(key: string, defaultValue?: T): T | undefined { const cached = this.cache[key]; - return cached !== undefined ? (cached as T) : defaultValue; + if (cached !== undefined) return cached as T; + this.triggerLazyFetch(key); + return defaultValue; } public get$(key: string): Observable; @@ -56,13 +74,34 @@ export class UserStorageClient implements IUserStorageClient { public get$(key: string, defaultValue?: T): Observable { const getCurrent = () => defaultValue !== undefined ? this.get(key, defaultValue) : this.get(key); + + // The lazy fetch is triggered inside getCurrent() → get() on first eval. return concat( defer(() => of(getCurrent())), - this.update$.pipe( - filter((u) => u.key === key), - map(() => getCurrent()) - ) - ); + // Merge explicit writes and lazy-fetch hydrations for this key. + new Observable((subscriber) => { + const writeSub = this.update$ + .pipe( + filter((u) => u.key === key), + map(() => getCurrent()) + ) + .subscribe(subscriber); + + const loadSub = this.loaded$ + .pipe( + filter((e) => e.key === key), + map(() => + defaultValue !== undefined ? this.get(key, defaultValue) : this.get(key) + ) + ) + .subscribe(subscriber); + + return () => { + writeSub.unsubscribe(); + loadSub.unsubscribe(); + }; + }) + ).pipe(share()); } public async set(key: string, value: T): Promise { @@ -70,7 +109,7 @@ export class UserStorageClient implements IUserStorageClient { await this.api.set(key, value); } catch (error) { const err = error instanceof Error ? error : new Error(String(error)); - this.updateErrors$.next(err); + this.httpErrors$.next(err); throw err; } @@ -84,7 +123,7 @@ export class UserStorageClient implements IUserStorageClient { await this.api.remove(key); } catch (error) { const err = error instanceof Error ? error : new Error(String(error)); - this.updateErrors$.next(err); + this.httpErrors$.next(err); throw err; } @@ -97,7 +136,30 @@ export class UserStorageClient implements IUserStorageClient { return this.update$.asObservable(); } - public getUpdateErrors$(): Observable { - return this.updateErrors$.asObservable(); + public getHttpError$(): Observable { + return this.httpErrors$.asObservable(); + } + + /** + * Initiates a single fire-and-forget GET for `key` if it is not yet cached + * and no prior fetch has been triggered for it in the lifetime of this client. + */ + private triggerLazyFetch(key: string): void { + if (this.cache[key] !== undefined || this.fetchInitiated.has(key)) return; + this.fetchInitiated.add(key); + + this.api.get(key).then( + (value) => { + this.cache[key] = value; + this.loaded$.next({ key, value }); + }, + (error: unknown) => { + const err = error instanceof Error ? error : new Error(String(error)); + this.httpErrors$.next(err); + // Remove key from the initiated set so callers can retry on + // re-mount if they want to (same session, same client instance). + this.fetchInitiated.delete(key); + } + ); } } diff --git a/src/core/packages/user-storage/browser-internal/src/user_storage_service.test.ts b/src/core/packages/user-storage/browser-internal/src/user_storage_service.test.ts index 91dbd8533552c..c68cfd268c11d 100644 --- a/src/core/packages/user-storage/browser-internal/src/user_storage_service.test.ts +++ b/src/core/packages/user-storage/browser-internal/src/user_storage_service.test.ts @@ -57,7 +57,7 @@ describe('UserStorageService', () => { const client = service.setup(buildDeps()); const update$ = client.getUpdate$(); - const errors$ = client.getUpdateErrors$(); + const errors$ = client.getHttpError$(); service.stop(); diff --git a/src/core/packages/user-storage/browser-mocks/src/client.mock.ts b/src/core/packages/user-storage/browser-mocks/src/client.mock.ts index dd9e5f1f8c113..71ab3ecbae9f3 100644 --- a/src/core/packages/user-storage/browser-mocks/src/client.mock.ts +++ b/src/core/packages/user-storage/browser-mocks/src/client.mock.ts @@ -18,5 +18,5 @@ export const clientMock = (): jest.Mocked => set: jest.fn().mockResolvedValue(undefined), remove: jest.fn().mockResolvedValue(undefined), getUpdate$: jest.fn().mockReturnValue(new Subject()), - getUpdateErrors$: jest.fn().mockReturnValue(new Subject()), + getHttpError$: jest.fn().mockReturnValue(new Subject()), }); diff --git a/src/core/packages/user-storage/browser/src/types.ts b/src/core/packages/user-storage/browser/src/types.ts index bc2b5896679ec..887ce374131fe 100644 --- a/src/core/packages/user-storage/browser/src/types.ts +++ b/src/core/packages/user-storage/browser/src/types.ts @@ -53,7 +53,7 @@ export interface IUserStorageClient { * Persists a new value via `PUT /internal/user_storage/{key}`. On success * the local cache is updated and subscribers to `get$` / `getUpdate$` are * notified. On HTTP failure the cache is left untouched, the error is - * published to `getUpdateErrors$`, and the returned promise rejects. + * published to `getHttpError$`, and the returned promise rejects. */ set(key: string, value: T): Promise; @@ -65,13 +65,14 @@ export interface IUserStorageClient { remove(key: string): Promise; /** - * Stream of every successful key update. + * Stream of every successful key update (write or remove). + * Does **not** emit for lazy-fetch cache hydrations. */ getUpdate$(): Observable; /** - * Stream of HTTP errors raised by `set` / `remove` after the call returned - * to the caller. Suitable for centralised toast / telemetry handling. + * Stream of HTTP errors raised by `set`, `remove`, or lazy-fetch calls. + * Suitable for centralised toast / telemetry handling. */ - getUpdateErrors$(): Observable; + getHttpError$(): Observable; } diff --git a/src/core/packages/user-storage/browser/src/use_user_storage.test.tsx b/src/core/packages/user-storage/browser/src/use_user_storage.test.tsx index bce234bb6b02f..6951baf89d1e2 100644 --- a/src/core/packages/user-storage/browser/src/use_user_storage.test.tsx +++ b/src/core/packages/user-storage/browser/src/use_user_storage.test.tsx @@ -36,7 +36,7 @@ const buildClient = (initial: Record = {}): IUserStorageClient }) as IUserStorageClient['remove'], getUpdate$: () => new BehaviorSubject({ type: 'remove' as const, key: '', oldValue: undefined }), - getUpdateErrors$: () => new BehaviorSubject(new Error('noop')), + getHttpError$: () => new BehaviorSubject(new Error('noop')), }; return client; }; diff --git a/src/core/packages/user-storage/common/src/types.ts b/src/core/packages/user-storage/common/src/types.ts index 0ba7a0c4ae17e..95009b7afa151 100644 --- a/src/core/packages/user-storage/common/src/types.ts +++ b/src/core/packages/user-storage/common/src/types.ts @@ -20,6 +20,20 @@ export interface UserStorageDefinition { defaultValue: T; /** Whether this key is per-space or global. */ scope: UserStorageScope; + /** + * When `true`, the effective value for this key is resolved server-side at + * first-paint time and embedded in the page HTML so the browser cache is + * pre-populated before any JavaScript runs. + * + * Keys without `serverInject: true` are loaded lazily: the browser cache + * starts empty for that key and the first `get(key)` / `get$(key)` call + * triggers a per-key HTTP fetch to hydrate the cache. + * + * Prefer `serverInject: true` only for keys whose values are needed on the + * critical rendering path. Large or rarely-read payloads should remain lazy + * to avoid bloating the initial HTML payload. + */ + serverInject?: boolean; } /** A record of key → definition, passed to `register()`. */ @@ -29,8 +43,12 @@ export type UserStorageRegistrations = Record; export interface IUserStorageClient { /** Resolve a single key: returns the user override or the registered default. */ get(key: string): Promise; - /** Resolve all registered keys (user overrides merged with defaults). */ - getAll(): Promise>; + /** + * Resolve all keys whose definition has `serverInject: true`, merging user + * overrides with registered defaults. Used exclusively by the rendering + * service to embed values in the initial HTML payload. + */ + getForInjection(): Promise>; /** Validate and persist a value for the current user. */ set(key: string, value: T): Promise; /** Remove the user override so the key falls back to its default. */ diff --git a/src/core/packages/user-storage/server-internal/src/routes/index.ts b/src/core/packages/user-storage/server-internal/src/routes/index.ts index 819fdc4141b18..7fac8847e3c5f 100644 --- a/src/core/packages/user-storage/server-internal/src/routes/index.ts +++ b/src/core/packages/user-storage/server-internal/src/routes/index.ts @@ -52,19 +52,30 @@ const createClientOrNull = ( export const registerRoutes = ({ router, definitions, logger }: RegisterRoutesParams) => { router.get( { - path: '/internal/user_storage', - validate: false, + path: '/internal/user_storage/{key}', + validate: { + params: z.object({ key: z.string() }), + }, security: { authz: AuthzDisabled.delegateToSOClient, }, }, - async (requestHandlerContext, _request, response) => { + async (requestHandlerContext, request, response) => { const coreCtx = await requestHandlerContext.core; const client = createClientOrNull(coreCtx, { definitions, logger }); if (!client) return response.forbidden({ body: { message: FORBIDDEN_MESSAGE } }); - const values = await client.getAll(); - return response.ok({ body: values }); + const { key } = request.params; + + try { + const value = await client.get(key); + return response.ok({ body: { value } }); + } catch (err) { + if (isUnregisteredKeyError(err)) { + return response.badRequest({ body: { message: err.message } }); + } + throw err; + } } ); diff --git a/src/core/packages/user-storage/server-internal/src/user_storage_client.test.ts b/src/core/packages/user-storage/server-internal/src/user_storage_client.test.ts index 4042c17e06544..8050f3169c3c2 100644 --- a/src/core/packages/user-storage/server-internal/src/user_storage_client.test.ts +++ b/src/core/packages/user-storage/server-internal/src/user_storage_client.test.ts @@ -28,11 +28,52 @@ const buildClient = (definitions: Map) => { return { client, savedObjectsClient, logger }; }; -describe('UserStorageClient.getAll()', () => { - it('issues a single bulkGet covering both SO types when both scopes are registered', async () => { +describe('UserStorageClient.getForInjection()', () => { + it('returns empty object when no definitions have serverInject: true', async () => { const definitions = new Map([ ['space:a', { schema: z.string(), defaultValue: 'a-default', scope: 'space' }], - ['global:b', { schema: z.number(), defaultValue: 0, scope: 'global' }], + ]); + const { client, savedObjectsClient } = buildClient(definitions); + + const result = await client.getForInjection(); + + expect(savedObjectsClient.bulkGet).not.toHaveBeenCalled(); + expect(result).toEqual({}); + }); + + it('only includes keys with serverInject: true, skipping non-injectable keys', async () => { + const definitions = new Map([ + [ + 'space:a', + { schema: z.string(), defaultValue: 'a-default', scope: 'space', serverInject: true }, + ], + ['space:b', { schema: z.string(), defaultValue: 'b-default', scope: 'space' }], + ]); + const { client, savedObjectsClient } = buildClient(definitions); + + savedObjectsClient.bulkGet.mockResolvedValue({ + saved_objects: [ + { + id: PROFILE_UID, + type: USER_STORAGE_SO_TYPE, + references: [], + attributes: { userId: PROFILE_UID, data: { 'space:a': 'hello', 'space:b': 'world' } }, + }, + ] as any, + }); + + const result = await client.getForInjection(); + + expect(result).toEqual({ 'space:a': 'hello' }); + }); + + it('issues a single bulkGet covering both SO types when injectable keys span both scopes', async () => { + const definitions = new Map([ + [ + 'space:a', + { schema: z.string(), defaultValue: 'a-default', scope: 'space', serverInject: true }, + ], + ['global:b', { schema: z.number(), defaultValue: 0, scope: 'global', serverInject: true }], ]); const { client, savedObjectsClient } = buildClient(definitions); @@ -53,7 +94,7 @@ describe('UserStorageClient.getAll()', () => { ] as any, }); - const result = await client.getAll(); + const result = await client.getForInjection(); expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(1); expect(savedObjectsClient.bulkGet).toHaveBeenCalledWith([ @@ -64,9 +105,13 @@ describe('UserStorageClient.getAll()', () => { expect(result).toEqual({ 'space:a': 'hello', 'global:b': 7 }); }); - it('only requests the registered scope when one scope is unused', async () => { + it('only requests the scope(s) used by injectable keys', async () => { const definitions = new Map([ - ['space:a', { schema: z.string(), defaultValue: 'a-default', scope: 'space' }], + [ + 'space:a', + { schema: z.string(), defaultValue: 'a-default', scope: 'space', serverInject: true }, + ], + ['global:b', { schema: z.number(), defaultValue: 0, scope: 'global' }], ]); const { client, savedObjectsClient } = buildClient(definitions); @@ -81,28 +126,20 @@ describe('UserStorageClient.getAll()', () => { ] as any, }); - await client.getAll(); + await client.getForInjection(); - expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(1); expect(savedObjectsClient.bulkGet).toHaveBeenCalledWith([ { type: USER_STORAGE_SO_TYPE, id: PROFILE_UID }, ]); }); - it('skips the SO request entirely when no definitions are registered', async () => { - const { client, savedObjectsClient } = buildClient(new Map()); - - const result = await client.getAll(); - - expect(savedObjectsClient.bulkGet).not.toHaveBeenCalled(); - expect(savedObjectsClient.get).not.toHaveBeenCalled(); - expect(result).toEqual({}); - }); - it('returns defaults when bulkGet reports a missing doc via the error field', async () => { const definitions = new Map([ - ['space:a', { schema: z.string(), defaultValue: 'a-default', scope: 'space' }], - ['global:b', { schema: z.number(), defaultValue: 42, scope: 'global' }], + [ + 'space:a', + { schema: z.string(), defaultValue: 'a-default', scope: 'space', serverInject: true }, + ], + ['global:b', { schema: z.number(), defaultValue: 42, scope: 'global', serverInject: true }], ]); const { client, savedObjectsClient } = buildClient(definitions); @@ -128,14 +165,17 @@ describe('UserStorageClient.getAll()', () => { ] as any, }); - const result = await client.getAll(); + const result = await client.getForInjection(); expect(result).toEqual({ 'space:a': 'a-default', 'global:b': 99 }); }); it('falls back to defaults and warns when a stored value fails schema validation', async () => { const definitions = new Map([ - ['space:a', { schema: z.string(), defaultValue: 'a-default', scope: 'space' }], + [ + 'space:a', + { schema: z.string(), defaultValue: 'a-default', scope: 'space', serverInject: true }, + ], ]); const { client, savedObjectsClient, logger } = buildClient(definitions); @@ -150,7 +190,7 @@ describe('UserStorageClient.getAll()', () => { ] as any, }); - const result = await client.getAll(); + const result = await client.getForInjection(); expect(result).toEqual({ 'space:a': 'a-default' }); expect(logger.warn).toHaveBeenCalledTimes(1); @@ -159,12 +199,15 @@ describe('UserStorageClient.getAll()', () => { it('propagates errors thrown by bulkGet itself (e.g. transport failure)', async () => { const definitions = new Map([ - ['space:a', { schema: z.string(), defaultValue: 'a-default', scope: 'space' }], + [ + 'space:a', + { schema: z.string(), defaultValue: 'a-default', scope: 'space', serverInject: true }, + ], ]); const { client, savedObjectsClient } = buildClient(definitions); savedObjectsClient.bulkGet.mockRejectedValue(new Error('boom')); - await expect(client.getAll()).rejects.toThrow('boom'); + await expect(client.getForInjection()).rejects.toThrow('boom'); }); }); diff --git a/src/core/packages/user-storage/server-internal/src/user_storage_client.ts b/src/core/packages/user-storage/server-internal/src/user_storage_client.ts index cd8f6a3949615..a3d779034ccf2 100644 --- a/src/core/packages/user-storage/server-internal/src/user_storage_client.ts +++ b/src/core/packages/user-storage/server-internal/src/user_storage_client.ts @@ -64,10 +64,15 @@ export class UserStorageClient implements IUserStorageClient { return definition.defaultValue as T; } - async getAll(): Promise> { + async getForInjection(): Promise> { + const injectableEntries = [...this.definitions.entries()].filter( + ([, d]) => d.serverInject === true + ); + if (injectableEntries.length === 0) return {}; + let hasSpace = false; let hasGlobal = false; - for (const d of this.definitions.values()) { + for (const [, d] of injectableEntries) { if (d.scope === 'space') hasSpace = true; else if (d.scope === 'global') hasGlobal = true; if (hasSpace && hasGlobal) break; @@ -96,7 +101,7 @@ export class UserStorageClient implements IUserStorageClient { } const result: Record = {}; - for (const [key, definition] of this.definitions) { + for (const [key, definition] of injectableEntries) { const data = definition.scope === 'space' ? spaceData : globalData; const raw = data?.[key]; if (raw != null) { From c215f23d8e8e4f28d5873c83fd4e08954807679d Mon Sep 17 00:00:00 2001 From: Timothy Sullivan Date: Thu, 21 May 2026 09:37:43 -0700 Subject: [PATCH 31/41] rename: `serverInject` -> `preload` --- src/core/packages/user-storage/README.mdx | 10 ++--- .../src/user_storage_client.ts | 2 +- .../user-storage/browser/src/types.ts | 5 ++- .../packages/user-storage/common/src/types.ts | 8 ++-- .../src/user_storage_client.test.ts | 38 +++++-------------- .../src/user_storage_client.ts | 4 +- 6 files changed, 24 insertions(+), 43 deletions(-) diff --git a/src/core/packages/user-storage/README.mdx b/src/core/packages/user-storage/README.mdx index b8ae166a9b6d9..0447bf799f060 100644 --- a/src/core/packages/user-storage/README.mdx +++ b/src/core/packages/user-storage/README.mdx @@ -66,11 +66,11 @@ The Zod schema runs: 2. At every `set`, against the incoming value (rejects invalid writes with `400`). 3. At every `get`, against the stored value. If the stored value fails to parse — usually because the schema has been narrowed since the value was written — the registered default is returned and a warning is logged. This means you can safely tighten a schema without writing a migration, but consumers should treat the resulting value reset as a possible UX outcome. -### Server-rendered injection +### Preloaded values By default, browser-side reads are **lazy**: the cache starts empty for a given key and the first `get(key)` / `get$(key)` call fires a `GET /internal/user_storage/{key}` request to hydrate it. While the request is in-flight `get(key)` returns `undefined` (or the provided `defaultValue`) and `get$(key)` emits `undefined` then emits again once the value arrives. -For keys on the critical rendering path, opt in to **eager injection** by setting `serverInject: true` in the key's `UserStorageDefinition`. Core then calls `getForInjection()` during server-side rendering and embeds only the opted-in keys into `` under `userStorage.values`, so those values are available synchronously before the first React render without any in-browser fetch. +For keys on the critical rendering path, opt in to **eager injection** by setting `preload: true` in the key's `UserStorageDefinition`. Core then calls `getForInjection()` during server-side rendering and embeds only the opted-in keys into `` under `userStorage.values`, so those values are available synchronously before the first React render without any in-browser fetch. ## Registering keys (server) @@ -90,13 +90,13 @@ export class MyPlugin implements Plugin { }), defaultValue: { hidden: [], order: [] }, scope: 'space', - serverInject: true, // embed in HTML at first paint; needed on the critical render path + preload: true, // embed in HTML at first paint; needed on the critical render path }, 'myPlugin:tour-dismissed': { schema: z.boolean(), defaultValue: false, scope: 'global', - // serverInject omitted — lazy-loaded on first access + // preload omitted — lazy-loaded on first access }, }); } @@ -150,7 +150,7 @@ await core.userStorage.set('myPlugin:nav-layout', nextLayout); await core.userStorage.remove('myPlugin:tour-dismissed'); ``` -Pass a `defaultValue` to `get(key, default)` so that consumers never see `undefined`. For keys with `serverInject: true` the cache is pre-populated at first paint so `get` is truly synchronous. For lazy-loaded keys, the first `get` triggers a background fetch and returns `undefined` (or `defaultValue`) until the response arrives. +Pass a `defaultValue` to `get(key, default)` so that consumers never see `undefined`. For keys with `preload: true` the cache is pre-populated at first paint so `get` is truly synchronous. For lazy-loaded keys, the first `get` triggers a background fetch and returns `undefined` (or `defaultValue`) until the response arrives. The client also exposes RxJS observables for live updates and HTTP errors: diff --git a/src/core/packages/user-storage/browser-internal/src/user_storage_client.ts b/src/core/packages/user-storage/browser-internal/src/user_storage_client.ts index 8442bd643ab56..660c7c67e1328 100644 --- a/src/core/packages/user-storage/browser-internal/src/user_storage_client.ts +++ b/src/core/packages/user-storage/browser-internal/src/user_storage_client.ts @@ -22,7 +22,7 @@ export interface UserStorageClientParams { /** * Browser-side {@link IUserStorageClient}: a synchronous in-memory cache - * seeded from server-injected metadata (for keys with `serverInject: true`), + * seeded from preloaded (server-injected) metadata (for keys with `preload: true`), * with HTTP-backed writes and per-key lazy fetching for non-injected keys. * * Lazy fetch behaviour: diff --git a/src/core/packages/user-storage/browser/src/types.ts b/src/core/packages/user-storage/browser/src/types.ts index 887ce374131fe..1bf2d8f805308 100644 --- a/src/core/packages/user-storage/browser/src/types.ts +++ b/src/core/packages/user-storage/browser/src/types.ts @@ -25,8 +25,9 @@ export type UserStorageUpdate = /** * Browser-side user storage client. Returns synchronously from an in-memory - * cache that is seeded from server-injected metadata at first paint, and is - * refreshed by `set` / `remove` after the corresponding HTTP write completes. + * cache that is seeded from preloaded (server-injected) metadata at first + * paint, and is refreshed by `set` / `remove` after the corresponding HTTP + * write completes. * * Distinct from the server-side `IUserStorageClient` (in * `@kbn/core-user-storage-common`) which is fully Promise-based. diff --git a/src/core/packages/user-storage/common/src/types.ts b/src/core/packages/user-storage/common/src/types.ts index 95009b7afa151..f16fd85605b0e 100644 --- a/src/core/packages/user-storage/common/src/types.ts +++ b/src/core/packages/user-storage/common/src/types.ts @@ -25,15 +25,15 @@ export interface UserStorageDefinition { * first-paint time and embedded in the page HTML so the browser cache is * pre-populated before any JavaScript runs. * - * Keys without `serverInject: true` are loaded lazily: the browser cache + * Keys without `preload: true` are loaded lazily: the browser cache * starts empty for that key and the first `get(key)` / `get$(key)` call * triggers a per-key HTTP fetch to hydrate the cache. * - * Prefer `serverInject: true` only for keys whose values are needed on the + * Prefer `preload: true` only for keys whose values are needed on the * critical rendering path. Large or rarely-read payloads should remain lazy * to avoid bloating the initial HTML payload. */ - serverInject?: boolean; + preload?: boolean; } /** A record of key → definition, passed to `register()`. */ @@ -44,7 +44,7 @@ export interface IUserStorageClient { /** Resolve a single key: returns the user override or the registered default. */ get(key: string): Promise; /** - * Resolve all keys whose definition has `serverInject: true`, merging user + * Resolve all keys whose definition has `preload: true`, merging user * overrides with registered defaults. Used exclusively by the rendering * service to embed values in the initial HTML payload. */ diff --git a/src/core/packages/user-storage/server-internal/src/user_storage_client.test.ts b/src/core/packages/user-storage/server-internal/src/user_storage_client.test.ts index 8050f3169c3c2..a483e8163bf05 100644 --- a/src/core/packages/user-storage/server-internal/src/user_storage_client.test.ts +++ b/src/core/packages/user-storage/server-internal/src/user_storage_client.test.ts @@ -29,7 +29,7 @@ const buildClient = (definitions: Map) => { }; describe('UserStorageClient.getForInjection()', () => { - it('returns empty object when no definitions have serverInject: true', async () => { + it('returns empty object when no definitions have preload: true', async () => { const definitions = new Map([ ['space:a', { schema: z.string(), defaultValue: 'a-default', scope: 'space' }], ]); @@ -41,12 +41,9 @@ describe('UserStorageClient.getForInjection()', () => { expect(result).toEqual({}); }); - it('only includes keys with serverInject: true, skipping non-injectable keys', async () => { + it('only includes keys with preload: true, skipping non-injectable keys', async () => { const definitions = new Map([ - [ - 'space:a', - { schema: z.string(), defaultValue: 'a-default', scope: 'space', serverInject: true }, - ], + ['space:a', { schema: z.string(), defaultValue: 'a-default', scope: 'space', preload: true }], ['space:b', { schema: z.string(), defaultValue: 'b-default', scope: 'space' }], ]); const { client, savedObjectsClient } = buildClient(definitions); @@ -69,11 +66,8 @@ describe('UserStorageClient.getForInjection()', () => { it('issues a single bulkGet covering both SO types when injectable keys span both scopes', async () => { const definitions = new Map([ - [ - 'space:a', - { schema: z.string(), defaultValue: 'a-default', scope: 'space', serverInject: true }, - ], - ['global:b', { schema: z.number(), defaultValue: 0, scope: 'global', serverInject: true }], + ['space:a', { schema: z.string(), defaultValue: 'a-default', scope: 'space', preload: true }], + ['global:b', { schema: z.number(), defaultValue: 0, scope: 'global', preload: true }], ]); const { client, savedObjectsClient } = buildClient(definitions); @@ -107,10 +101,7 @@ describe('UserStorageClient.getForInjection()', () => { it('only requests the scope(s) used by injectable keys', async () => { const definitions = new Map([ - [ - 'space:a', - { schema: z.string(), defaultValue: 'a-default', scope: 'space', serverInject: true }, - ], + ['space:a', { schema: z.string(), defaultValue: 'a-default', scope: 'space', preload: true }], ['global:b', { schema: z.number(), defaultValue: 0, scope: 'global' }], ]); const { client, savedObjectsClient } = buildClient(definitions); @@ -135,11 +126,8 @@ describe('UserStorageClient.getForInjection()', () => { it('returns defaults when bulkGet reports a missing doc via the error field', async () => { const definitions = new Map([ - [ - 'space:a', - { schema: z.string(), defaultValue: 'a-default', scope: 'space', serverInject: true }, - ], - ['global:b', { schema: z.number(), defaultValue: 42, scope: 'global', serverInject: true }], + ['space:a', { schema: z.string(), defaultValue: 'a-default', scope: 'space', preload: true }], + ['global:b', { schema: z.number(), defaultValue: 42, scope: 'global', preload: true }], ]); const { client, savedObjectsClient } = buildClient(definitions); @@ -172,10 +160,7 @@ describe('UserStorageClient.getForInjection()', () => { it('falls back to defaults and warns when a stored value fails schema validation', async () => { const definitions = new Map([ - [ - 'space:a', - { schema: z.string(), defaultValue: 'a-default', scope: 'space', serverInject: true }, - ], + ['space:a', { schema: z.string(), defaultValue: 'a-default', scope: 'space', preload: true }], ]); const { client, savedObjectsClient, logger } = buildClient(definitions); @@ -199,10 +184,7 @@ describe('UserStorageClient.getForInjection()', () => { it('propagates errors thrown by bulkGet itself (e.g. transport failure)', async () => { const definitions = new Map([ - [ - 'space:a', - { schema: z.string(), defaultValue: 'a-default', scope: 'space', serverInject: true }, - ], + ['space:a', { schema: z.string(), defaultValue: 'a-default', scope: 'space', preload: true }], ]); const { client, savedObjectsClient } = buildClient(definitions); diff --git a/src/core/packages/user-storage/server-internal/src/user_storage_client.ts b/src/core/packages/user-storage/server-internal/src/user_storage_client.ts index a3d779034ccf2..7d2b8235a358c 100644 --- a/src/core/packages/user-storage/server-internal/src/user_storage_client.ts +++ b/src/core/packages/user-storage/server-internal/src/user_storage_client.ts @@ -65,9 +65,7 @@ export class UserStorageClient implements IUserStorageClient { } async getForInjection(): Promise> { - const injectableEntries = [...this.definitions.entries()].filter( - ([, d]) => d.serverInject === true - ); + const injectableEntries = [...this.definitions.entries()].filter(([, d]) => d.preload === true); if (injectableEntries.length === 0) return {}; let hasSpace = false; From fcba20b3962a0781ee9e5b8fb0aa2c450b1517bd Mon Sep 17 00:00:00 2001 From: Timothy Sullivan Date: Thu, 21 May 2026 09:55:30 -0700 Subject: [PATCH 32/41] use lazy value in scout functional test --- .../ui/tests/render.spec.ts | 7 +++++++ .../public/components/app.tsx | 19 ++++++++++++++----- .../user_storage_test/server/plugin.ts | 11 +++++++++++ 3 files changed, 32 insertions(+), 5 deletions(-) diff --git a/src/core/packages/user-storage/test/scout_user_storage/ui/tests/render.spec.ts b/src/core/packages/user-storage/test/scout_user_storage/ui/tests/render.spec.ts index 0cf7f62aae121..8d498e9e91ab0 100644 --- a/src/core/packages/user-storage/test/scout_user_storage/ui/tests/render.spec.ts +++ b/src/core/packages/user-storage/test/scout_user_storage/ui/tests/render.spec.ts @@ -14,6 +14,7 @@ import { expect } from '@kbn/scout/ui'; // `src/platform/test/user_storage/plugins/user_storage_test/server/plugin.ts`. // Keep both in sync when changing the test fixture. const TEST_STRING_KEY_DEFAULT = 'default_value'; +const LAZY_TEST_STRING_KEY_DEFAULT = 'lazy_default_value'; test.describe('User Storage - first paint', { tag: [...tags.stateful.classic] }, () => { test.beforeEach(async ({ browserAuth, page }) => { @@ -26,4 +27,10 @@ test.describe('User Storage - first paint', { tag: [...tags.stateful.classic] }, TEST_STRING_KEY_DEFAULT ); }); + + test('renders the registered default for test:string_key_lazy', async ({ page }) => { + await expect(page.testSubj.locator('userStorageTest:lazy-string-key-value')).toHaveText( + LAZY_TEST_STRING_KEY_DEFAULT + ); + }); }); diff --git a/src/platform/test/user_storage/plugins/user_storage_test/public/components/app.tsx b/src/platform/test/user_storage/plugins/user_storage_test/public/components/app.tsx index 312df1262054e..f72334dabfd0e 100644 --- a/src/platform/test/user_storage/plugins/user_storage_test/public/components/app.tsx +++ b/src/platform/test/user_storage/plugins/user_storage_test/public/components/app.tsx @@ -15,12 +15,21 @@ import { } from '@kbn/core-user-storage-browser'; const StringKeyValue = () => { - const [value] = useUserStorage('test:string_key'); + const [preloadedStringValue] = useUserStorage('test:string_key'); + const [lazyStringValue] = useUserStorage('test:string_key_lazy'); return ( -
- String key: - {value} -
+ <> +
+ String key (preloaded): + + {preloadedStringValue} + +
+
+ String key (lazy): + {lazyStringValue} +
+ ); }; diff --git a/src/platform/test/user_storage/plugins/user_storage_test/server/plugin.ts b/src/platform/test/user_storage/plugins/user_storage_test/server/plugin.ts index fea8f7f881c64..3f754eade5bd1 100644 --- a/src/platform/test/user_storage/plugins/user_storage_test/server/plugin.ts +++ b/src/platform/test/user_storage/plugins/user_storage_test/server/plugin.ts @@ -17,11 +17,19 @@ export class UserStorageTestPlugin implements Plugin { schema: z.string(), defaultValue: 'default_value', scope: 'space', + preload: true, + }, + 'test:string_key_lazy': { + schema: z.string(), + defaultValue: 'lazy_default_value', + scope: 'space', + preload: false, // make it lazy }, 'test:number_key': { schema: z.number(), defaultValue: 42, scope: 'global', + preload: true, }, 'test:object_key': { schema: z.object({ @@ -38,16 +46,19 @@ export class UserStorageTestPlugin implements Plugin { pinnedItems: [], }, scope: 'space', + preload: true, }, 'test:boolean_key': { schema: z.boolean(), defaultValue: false, scope: 'global', + preload: true, }, 'test:array_key': { schema: z.array(z.string()), defaultValue: [], scope: 'space', + preload: true, }, }); } From 5d6fa665062249b4cb577c3816cf01326b82c312 Mon Sep 17 00:00:00 2001 From: Timothy Sullivan Date: Thu, 21 May 2026 10:47:49 -0700 Subject: [PATCH 33/41] schema not allow null --- .../src/user_storage_service.test.ts | 82 +++++++++++++++++++ .../src/user_storage_service.ts | 6 ++ 2 files changed, 88 insertions(+) create mode 100644 src/core/packages/user-storage/server-internal/src/user_storage_service.test.ts diff --git a/src/core/packages/user-storage/server-internal/src/user_storage_service.test.ts b/src/core/packages/user-storage/server-internal/src/user_storage_service.test.ts new file mode 100644 index 0000000000000..eb1e2f7f92ace --- /dev/null +++ b/src/core/packages/user-storage/server-internal/src/user_storage_service.test.ts @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { z } from '@kbn/zod/v4'; +import { loggerMock } from '@kbn/logging-mocks'; +import type { CoreContext } from '@kbn/core-base-server-internal'; +import { UserStorageService } from './user_storage_service'; + +jest.mock('./routes', () => ({ registerRoutes: jest.fn() })); + +const buildRegister = () => { + const coreContext = { + logger: { get: () => loggerMock.create() }, + } as unknown as CoreContext; + + const service = new UserStorageService(coreContext); + const { register } = service.setup({ + http: { + createRouter: jest.fn().mockReturnValue({ + get: jest.fn(), + put: jest.fn(), + delete: jest.fn(), + }), + }, + savedObjects: { registerType: jest.fn() }, + } as any); + + return register; +}; + +describe('UserStorageService.register()', () => { + it('accepts a valid schema with a matching defaultValue', () => { + const register = buildRegister(); + expect(() => + register({ 'test:key': { schema: z.string(), defaultValue: 'hello', scope: 'space' } }) + ).not.toThrow(); + }); + + it('throws when the defaultValue does not match the schema', () => { + const register = buildRegister(); + expect(() => + register({ 'test:key': { schema: z.string(), defaultValue: 42 as any, scope: 'space' } }) + ).toThrow(/does not match its schema/); + }); + + it('throws when the same key is registered twice', () => { + const register = buildRegister(); + register({ 'test:key': { schema: z.string(), defaultValue: 'x', scope: 'space' } }); + expect(() => + register({ 'test:key': { schema: z.string(), defaultValue: 'y', scope: 'space' } }) + ).toThrow(/already been registered/); + }); + + it('throws when the schema accepts null (reserved tombstone value)', () => { + const register = buildRegister(); + expect(() => + register({ + 'test:key': { schema: z.string().nullable(), defaultValue: null as any, scope: 'space' }, + }) + ).toThrow(/must not accept null/); + }); + + it('throws when a z.null() schema is registered', () => { + const register = buildRegister(); + expect(() => + register({ 'test:key': { schema: z.null(), defaultValue: null as any, scope: 'space' } }) + ).toThrow(/must not accept null/); + }); + + it('accepts a schema that explicitly rejects null (z.string() is non-nullable by default)', () => { + const register = buildRegister(); + expect(() => + register({ 'test:key': { schema: z.string(), defaultValue: '', scope: 'global' } }) + ).not.toThrow(); + }); +}); diff --git a/src/core/packages/user-storage/server-internal/src/user_storage_service.ts b/src/core/packages/user-storage/server-internal/src/user_storage_service.ts index a8a6b748b7ac1..0d228144620e7 100644 --- a/src/core/packages/user-storage/server-internal/src/user_storage_service.ts +++ b/src/core/packages/user-storage/server-internal/src/user_storage_service.ts @@ -74,6 +74,12 @@ export class UserStorageService { `userStorage key [${key}] has a defaultValue that does not match its schema: ${message}` ); } + if (definition.schema.safeParse(null).success) { + throw new Error( + `userStorage key [${key}] schema must not accept null. ` + + `null is reserved as the removal tombstone in the underlying storage layer.` + ); + } this.definitions.set(key, definition); } }, From 5ce92126e7f4981c5027a3b85c51c353454c92ad Mon Sep 17 00:00:00 2001 From: Timothy Sullivan Date: Thu, 21 May 2026 10:48:26 -0700 Subject: [PATCH 34/41] add peek to the API to return cached value without lazy fetch --- .../src/user_storage_client.test.ts | 23 +++++++++++++++++++ .../src/user_storage_client.ts | 7 ++++++ .../browser-mocks/src/client.mock.ts | 1 + .../user-storage/browser/src/types.ts | 17 ++++++++++++++ .../browser/src/use_user_storage.test.tsx | 4 ++++ .../browser/src/use_user_storage.ts | 5 +++- 6 files changed, 56 insertions(+), 1 deletion(-) diff --git a/src/core/packages/user-storage/browser-internal/src/user_storage_client.test.ts b/src/core/packages/user-storage/browser-internal/src/user_storage_client.test.ts index d4edba423c20a..a3619a25f8a68 100644 --- a/src/core/packages/user-storage/browser-internal/src/user_storage_client.test.ts +++ b/src/core/packages/user-storage/browser-internal/src/user_storage_client.test.ts @@ -26,6 +26,29 @@ const buildClient = (initialValues: Record = {}) => { }; describe('UserStorageClient', () => { + describe('peek', () => { + it('returns cached values without triggering a lazy fetch', () => { + const { client, api } = buildClient({ a: 1 }); + + expect(client.peek('a')).toBe(1); + expect(api.get).not.toHaveBeenCalled(); + }); + + it('returns undefined for a missing key without triggering a lazy fetch', () => { + const { client, api } = buildClient({}); + + expect(client.peek('missing')).toBeUndefined(); + expect(api.get).not.toHaveBeenCalled(); + }); + + it('returns defaultValue for a missing key without triggering a lazy fetch', () => { + const { client, api } = buildClient({}); + + expect(client.peek('missing', 'fallback')).toBe('fallback'); + expect(api.get).not.toHaveBeenCalled(); + }); + }); + describe('get', () => { it('returns cached values seeded from initialValues', () => { const { client } = buildClient({ a: 1, b: 'two' }); diff --git a/src/core/packages/user-storage/browser-internal/src/user_storage_client.ts b/src/core/packages/user-storage/browser-internal/src/user_storage_client.ts index 660c7c67e1328..8d6a9fc6d3b0d 100644 --- a/src/core/packages/user-storage/browser-internal/src/user_storage_client.ts +++ b/src/core/packages/user-storage/browser-internal/src/user_storage_client.ts @@ -60,6 +60,13 @@ export class UserStorageClient implements IUserStorageClient { }); } + public peek(key: string): T | undefined; + public peek(key: string, defaultValue: T): T; + public peek(key: string, defaultValue?: T): T | undefined { + const cached = this.cache[key]; + return cached !== undefined ? (cached as T) : defaultValue; + } + public get(key: string): T | undefined; public get(key: string, defaultValue: T): T; public get(key: string, defaultValue?: T): T | undefined { diff --git a/src/core/packages/user-storage/browser-mocks/src/client.mock.ts b/src/core/packages/user-storage/browser-mocks/src/client.mock.ts index 71ab3ecbae9f3..2eb4a36951b80 100644 --- a/src/core/packages/user-storage/browser-mocks/src/client.mock.ts +++ b/src/core/packages/user-storage/browser-mocks/src/client.mock.ts @@ -13,6 +13,7 @@ import type { IUserStorageClient } from '@kbn/core-user-storage-browser'; export const clientMock = (): jest.Mocked => lazyObject({ + peek: jest.fn(), get: jest.fn(), get$: jest.fn().mockReturnValue(new Subject()), set: jest.fn().mockResolvedValue(undefined), diff --git a/src/core/packages/user-storage/browser/src/types.ts b/src/core/packages/user-storage/browser/src/types.ts index 1bf2d8f805308..5238509560e51 100644 --- a/src/core/packages/user-storage/browser/src/types.ts +++ b/src/core/packages/user-storage/browser/src/types.ts @@ -35,9 +35,26 @@ export type UserStorageUpdate = * @public */ export interface IUserStorageClient { + /** + * Pure synchronous read from the local cache with no side effects. + * Returns `undefined` when no cached value exists for the key and no + * `defaultValue` is provided. + * + * Unlike `get`, `peek` never triggers a lazy fetch, making it safe to + * call during React render (which may be invoked multiple times before + * a commit under concurrent mode). + */ + peek(key: string): T | undefined; + peek(key: string, defaultValue: T): T; + /** * Synchronous read from the local cache. Returns `undefined` when no cached * value exists for the key and no `defaultValue` is provided. + * + * For keys without `preload: true`, the first call for an uncached key + * triggers a fire-and-forget lazy HTTP fetch in the background. Prefer + * `peek` in render functions; use `get` in imperative / effect code where + * triggering the fetch on first access is the intended behaviour. */ get(key: string): T | undefined; get(key: string, defaultValue: T): T; diff --git a/src/core/packages/user-storage/browser/src/use_user_storage.test.tsx b/src/core/packages/user-storage/browser/src/use_user_storage.test.tsx index 6951baf89d1e2..bd14de38832a0 100644 --- a/src/core/packages/user-storage/browser/src/use_user_storage.test.tsx +++ b/src/core/packages/user-storage/browser/src/use_user_storage.test.tsx @@ -18,6 +18,10 @@ const buildClient = (initial: Record = {}): IUserStorageClient const cache: Record = { ...initial }; const subject$ = new BehaviorSubject>(cache); const client: IUserStorageClient = { + peek: ((key: string, defaultValue?: unknown) => + (cache[key] !== undefined + ? cache[key] + : defaultValue) as never) as IUserStorageClient['peek'], get: ((key: string, defaultValue?: unknown) => (cache[key] !== undefined ? cache[key] : defaultValue) as never) as IUserStorageClient['get'], get$: ((key: string, defaultValue?: unknown) => { diff --git a/src/core/packages/user-storage/browser/src/use_user_storage.ts b/src/core/packages/user-storage/browser/src/use_user_storage.ts index dd853472d100c..5d2a37036eacf 100644 --- a/src/core/packages/user-storage/browser/src/use_user_storage.ts +++ b/src/core/packages/user-storage/browser/src/use_user_storage.ts @@ -67,9 +67,12 @@ export function useUserStorage( () => (defaultValue !== undefined ? client.get$(key, defaultValue) : client.get$(key)), [client, key, defaultValue] ); + // Use peek (pure, no side effects) for the synchronous initial render value. + // The lazy fetch is triggered by the get$() subscription inside useObservable, + // which runs inside an effect after the first commit — safe under concurrent mode. const value = useObservable( observable$, - defaultValue !== undefined ? client.get(key, defaultValue) : client.get(key) + defaultValue !== undefined ? client.peek(key, defaultValue) : client.peek(key) ); const set = useCallback>( (newValue) => client.set(key, newValue), From bfe43d79f6df0720bf51d1445a58bba7715a295d Mon Sep 17 00:00:00 2001 From: Timothy Sullivan Date: Thu, 21 May 2026 10:49:44 -0700 Subject: [PATCH 35/41] fix api tests for per-key fetch --- .../scout_user_storage/api/tests/crud.spec.ts | 43 +++++++----- .../api/tests/forbidden.spec.ts | 2 +- .../scout_user_storage/api/tests/helpers.ts | 69 +++++++++++++++---- .../api/tests/scope_isolation.spec.ts | 12 ++-- .../api/tests/validation.spec.ts | 20 +++--- 5 files changed, 101 insertions(+), 45 deletions(-) diff --git a/src/core/packages/user-storage/test/scout_user_storage/api/tests/crud.spec.ts b/src/core/packages/user-storage/test/scout_user_storage/api/tests/crud.spec.ts index 5801794b742f6..58464b246afdd 100644 --- a/src/core/packages/user-storage/test/scout_user_storage/api/tests/crud.spec.ts +++ b/src/core/packages/user-storage/test/scout_user_storage/api/tests/crud.spec.ts @@ -27,23 +27,25 @@ apiTest.describe('User Storage - CRUD', { tag: [...tags.stateful.classic] }, () }); apiTest('GET returns all registered keys with their default values', async ({ apiClient }) => { - const response = await h.get(apiClient); - expect(response).toHaveStatusCode(200); + const response = await h.getAllKeys(apiClient); + expect(response.statusCode).toBe(200); expect(response.body).toStrictEqual(DEFAULT_VALUES); }); apiTest('PUT sets a valid string value', async ({ apiClient }) => { expect(await h.put(apiClient, 'test:string_key', 'custom_value')).toHaveStatusCode(200); - const response = await h.get(apiClient); - expect(response.body['test:string_key']).toBe('custom_value'); + const response = await h.getKey(apiClient, 'test:string_key'); + expect(response).toHaveStatusCode(200); + expect(response.body.value).toBe('custom_value'); }); apiTest('PUT sets a valid number value', async ({ apiClient }) => { expect(await h.put(apiClient, 'test:number_key', 99)).toHaveStatusCode(200); - const response = await h.get(apiClient); - expect(response.body['test:number_key']).toBe(99); + const response = await h.getKey(apiClient, 'test:number_key'); + expect(response).toHaveStatusCode(200); + expect(response.body.value).toBe(99); }); apiTest('PUT returns 400 for an unregistered key', async ({ apiClient }) => { @@ -57,8 +59,9 @@ apiTest.describe('User Storage - CRUD', { tag: [...tags.stateful.classic] }, () expect(await h.del(apiClient, 'test:string_key')).toHaveStatusCode(200); - const response = await h.get(apiClient); - expect(response.body['test:string_key']).toBe('default_value'); + const response = await h.getKey(apiClient, 'test:string_key'); + expect(response).toHaveStatusCode(200); + expect(response.body.value).toBe('default_value'); }); apiTest('DELETE returns 400 for an unregistered key', async ({ apiClient }) => { @@ -75,30 +78,34 @@ apiTest.describe('User Storage - CRUD', { tag: [...tags.stateful.classic] }, () }; expect(await h.put(apiClient, 'test:object_key', value)).toHaveStatusCode(200); - const response = await h.get(apiClient); - expect(response.body['test:object_key']).toStrictEqual(value); + const response = await h.getKey(apiClient, 'test:object_key'); + expect(response).toHaveStatusCode(200); + expect(response.body.value).toStrictEqual(value); }); apiTest('PUT/GET array round-trips correctly', async ({ apiClient }) => { expect(await h.put(apiClient, 'test:array_key', ['a', 'b', 'c'])).toHaveStatusCode(200); - const response = await h.get(apiClient); - expect(response.body['test:array_key']).toStrictEqual(['a', 'b', 'c']); + const response = await h.getKey(apiClient, 'test:array_key'); + expect(response).toHaveStatusCode(200); + expect(response.body.value).toStrictEqual(['a', 'b', 'c']); }); apiTest('PUT/GET boolean round-trips correctly', async ({ apiClient }) => { expect(await h.put(apiClient, 'test:boolean_key', true)).toHaveStatusCode(200); - const response = await h.get(apiClient); - expect(response.body['test:boolean_key']).toBe(true); + const response = await h.getKey(apiClient, 'test:boolean_key'); + expect(response).toHaveStatusCode(200); + expect(response.body.value).toBe(true); }); apiTest('Multiple PUTs overwrite — last value wins', async ({ apiClient }) => { await h.put(apiClient, 'test:string_key', 'first'); await h.put(apiClient, 'test:string_key', 'second'); - const response = await h.get(apiClient); - expect(response.body['test:string_key']).toBe('second'); + const response = await h.getKey(apiClient, 'test:string_key'); + expect(response).toHaveStatusCode(200); + expect(response.body.value).toBe('second'); }); apiTest( @@ -112,8 +119,8 @@ apiTest.describe('User Storage - CRUD', { tag: [...tags.stateful.classic] }, () pinnedItems: ['item-1'], }); - const response = await h.get(apiClient); - expect(response).toHaveStatusCode(200); + const response = await h.getAllKeys(apiClient); + expect(response.statusCode).toBe(200); expect(response.body).toMatchObject({ 'test:string_key': 'custom', 'test:number_key': 100, diff --git a/src/core/packages/user-storage/test/scout_user_storage/api/tests/forbidden.spec.ts b/src/core/packages/user-storage/test/scout_user_storage/api/tests/forbidden.spec.ts index f3fb9e05e566f..f131ed4b99cca 100644 --- a/src/core/packages/user-storage/test/scout_user_storage/api/tests/forbidden.spec.ts +++ b/src/core/packages/user-storage/test/scout_user_storage/api/tests/forbidden.spec.ts @@ -24,7 +24,7 @@ apiTest.describe( }); apiTest('GET returns 403 with API key auth', async ({ apiClient }) => { - const response = await h.get(apiClient); + const response = await h.getKey(apiClient, 'test:string_key'); expect(response).toHaveStatusCode(403); }); diff --git a/src/core/packages/user-storage/test/scout_user_storage/api/tests/helpers.ts b/src/core/packages/user-storage/test/scout_user_storage/api/tests/helpers.ts index 364714adfd49f..900435c478bcc 100644 --- a/src/core/packages/user-storage/test/scout_user_storage/api/tests/helpers.ts +++ b/src/core/packages/user-storage/test/scout_user_storage/api/tests/helpers.ts @@ -54,7 +54,64 @@ interface ApiClient { export const createHelpers = (headersGetter: () => Record) => { const headers = () => ({ ...INTERNAL_HEADERS, ...headersGetter() }); + const getKey = (apiClient: ApiClient, key: string) => + apiClient.get(`internal/user_storage/${key}`, { + headers: headers(), + responseType: 'json', + }); + + const getKeyInSpace = (apiClient: ApiClient, spaceId: string, key: string) => + apiClient.get(`s/${spaceId}/internal/user_storage/${key}`, { + headers: headers(), + responseType: 'json', + }); + + /** + * Fetches every key in ALL_KEYS individually and assembles the results into + * a response-like object: `{ statusCode, body: Record }`. + * The `statusCode` reflects the last non-200 response seen, or 200 if all + * succeeded. Use this when a test needs to assert across multiple keys at + * once while staying compatible with the per-key route. + */ + const getAllKeys = async ( + apiClient: ApiClient + ): Promise<{ statusCode: number; body: Record }> => { + const body: Record = {}; + let lastFailCode = 200; + for (const key of ALL_KEYS) { + const res = await getKey(apiClient, key); + if (res.statusCode !== 200) { + lastFailCode = res.statusCode; + continue; + } + body[key] = res.body.value; + } + return { statusCode: lastFailCode, body }; + }; + + const getAllKeysInSpace = async ( + apiClient: ApiClient, + spaceId: string + ): Promise<{ statusCode: number; body: Record }> => { + const body: Record = {}; + let lastFailCode = 200; + for (const key of ALL_KEYS) { + const res = await getKeyInSpace(apiClient, spaceId, key); + if (res.statusCode !== 200) { + lastFailCode = res.statusCode; + continue; + } + body[key] = res.body.value; + } + return { statusCode: lastFailCode, body }; + }; + return { + getKey, + getKeyInSpace, + getAllKeys, + getAllKeysInSpace, + put: (apiClient: ApiClient, key: string, value: unknown) => apiClient.put(`internal/user_storage/${key}`, { headers: headers(), @@ -62,12 +119,6 @@ export const createHelpers = (headersGetter: () => Record) => { responseType: 'json', }), - get: (apiClient: ApiClient) => - apiClient.get('internal/user_storage', { - headers: headers(), - responseType: 'json', - }), - del: (apiClient: ApiClient, key: string) => apiClient.delete(`internal/user_storage/${key}`, { headers: headers(), @@ -81,12 +132,6 @@ export const createHelpers = (headersGetter: () => Record) => { responseType: 'json', }), - getInSpace: (apiClient: ApiClient, spaceId: string) => - apiClient.get(`s/${spaceId}/internal/user_storage`, { - headers: headers(), - responseType: 'json', - }), - delInSpace: (apiClient: ApiClient, spaceId: string, key: string) => apiClient.delete(`s/${spaceId}/internal/user_storage/${key}`, { headers: headers(), diff --git a/src/core/packages/user-storage/test/scout_user_storage/api/tests/scope_isolation.spec.ts b/src/core/packages/user-storage/test/scout_user_storage/api/tests/scope_isolation.spec.ts index a1cf56ab2de5a..541adc49b71ed 100644 --- a/src/core/packages/user-storage/test/scout_user_storage/api/tests/scope_isolation.spec.ts +++ b/src/core/packages/user-storage/test/scout_user_storage/api/tests/scope_isolation.spec.ts @@ -46,9 +46,9 @@ apiTest.describe('User Storage - Scope Isolation', { tag: [...tags.stateful.clas async ({ apiClient }) => { await h.put(apiClient, 'test:string_key', 'default-space-value'); - const response = await h.getInSpace(apiClient, TEST_SPACE); + const response = await h.getKeyInSpace(apiClient, TEST_SPACE, 'test:string_key'); expect(response).toHaveStatusCode(200); - expect(response.body['test:string_key']).toBe('default_value'); + expect(response.body.value).toBe('default_value'); } ); @@ -57,17 +57,17 @@ apiTest.describe('User Storage - Scope Isolation', { tag: [...tags.stateful.clas async ({ apiClient }) => { await h.put(apiClient, 'test:number_key', 999); - const response = await h.getInSpace(apiClient, TEST_SPACE); + const response = await h.getKeyInSpace(apiClient, TEST_SPACE, 'test:number_key'); expect(response).toHaveStatusCode(200); - expect(response.body['test:number_key']).toBe(999); + expect(response.body.value).toBe(999); } ); apiTest('global key set from another space is visible everywhere', async ({ apiClient }) => { await h.putInSpace(apiClient, TEST_SPACE, 'test:boolean_key', true); - const response = await h.get(apiClient); + const response = await h.getKey(apiClient, 'test:boolean_key'); expect(response).toHaveStatusCode(200); - expect(response.body['test:boolean_key']).toBe(true); + expect(response.body.value).toBe(true); }); }); diff --git a/src/core/packages/user-storage/test/scout_user_storage/api/tests/validation.spec.ts b/src/core/packages/user-storage/test/scout_user_storage/api/tests/validation.spec.ts index 7a3d2093b7eb0..97dad3345afbf 100644 --- a/src/core/packages/user-storage/test/scout_user_storage/api/tests/validation.spec.ts +++ b/src/core/packages/user-storage/test/scout_user_storage/api/tests/validation.spec.ts @@ -81,26 +81,30 @@ apiTest.describe('User Storage - Schema Validation', { tag: [...tags.stateful.cl apiTest('PUT preserves empty string', async ({ apiClient }) => { expect(await h.put(apiClient, 'test:string_key', '')).toHaveStatusCode(200); - const response = await h.get(apiClient); - expect(response.body['test:string_key']).toBe(''); + const response = await h.getKey(apiClient, 'test:string_key'); + expect(response).toHaveStatusCode(200); + expect(response.body.value).toBe(''); }); apiTest('PUT preserves zero', async ({ apiClient }) => { expect(await h.put(apiClient, 'test:number_key', 0)).toHaveStatusCode(200); - const response = await h.get(apiClient); - expect(response.body['test:number_key']).toBe(0); + const response = await h.getKey(apiClient, 'test:number_key'); + expect(response).toHaveStatusCode(200); + expect(response.body.value).toBe(0); }); apiTest('PUT preserves negative number', async ({ apiClient }) => { expect(await h.put(apiClient, 'test:number_key', -10)).toHaveStatusCode(200); - const response = await h.get(apiClient); - expect(response.body['test:number_key']).toBe(-10); + const response = await h.getKey(apiClient, 'test:number_key'); + expect(response).toHaveStatusCode(200); + expect(response.body.value).toBe(-10); }); apiTest('PUT preserves empty array', async ({ apiClient }) => { expect(await h.put(apiClient, 'test:array_key', [])).toHaveStatusCode(200); - const response = await h.get(apiClient); - expect(response.body['test:array_key']).toStrictEqual([]); + const response = await h.getKey(apiClient, 'test:array_key'); + expect(response).toHaveStatusCode(200); + expect(response.body.value).toStrictEqual([]); }); apiTest('DELETE on a key that was never set is a no-op', async ({ apiClient }) => { From e49e3f948a5a560505cc9b5180cac7ecfddc955f Mon Sep 17 00:00:00 2001 From: Timothy Sullivan Date: Thu, 21 May 2026 13:05:17 -0700 Subject: [PATCH 36/41] fix types for jest test --- .../server/integration_tests/user_storage/remove.test.ts | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/core/server/integration_tests/user_storage/remove.test.ts b/src/core/server/integration_tests/user_storage/remove.test.ts index ac80ba80b3ad2..7fcaa07efaea5 100644 --- a/src/core/server/integration_tests/user_storage/remove.test.ts +++ b/src/core/server/integration_tests/user_storage/remove.test.ts @@ -110,12 +110,4 @@ describe('UserStorage remove() / null-merge behavior', () => { expect(await userStorage.get('test:string_a')).toBe('default_a'); expect(await userStorage.get('test:string_b')).toBe('value_b'); }); - - it('getAll() treats null the same as undefined (returns default)', async () => { - await userStorage.set('test:string_a', 'custom_value'); - await userStorage.remove('test:string_a'); - - const all = await userStorage.getAll(); - expect(all['test:string_a']).toBe('default_a'); - }); }); From 910eadaeda232c5e93f253a16890aeec030a54b8 Mon Sep 17 00:00:00 2001 From: Timothy Sullivan Date: Thu, 21 May 2026 15:24:29 -0700 Subject: [PATCH 37/41] fix scout ui test --- .../plugins/user_storage_test/public/components/app.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/platform/test/user_storage/plugins/user_storage_test/public/components/app.tsx b/src/platform/test/user_storage/plugins/user_storage_test/public/components/app.tsx index f72334dabfd0e..3f31c503e46fa 100644 --- a/src/platform/test/user_storage/plugins/user_storage_test/public/components/app.tsx +++ b/src/platform/test/user_storage/plugins/user_storage_test/public/components/app.tsx @@ -19,11 +19,9 @@ const StringKeyValue = () => { const [lazyStringValue] = useUserStorage('test:string_key_lazy'); return ( <> -
+
String key (preloaded): - - {preloadedStringValue} - + {preloadedStringValue}
String key (lazy): From 0e65c229c6f8f75f7fae66a3015d717c569b4ded Mon Sep 17 00:00:00 2001 From: Timothy Sullivan Date: Fri, 22 May 2026 11:09:43 -0700 Subject: [PATCH 38/41] return parsed value from PUT and use it for browser cache --- .../src/user_storage_api.test.ts | 10 ++++--- .../browser-internal/src/user_storage_api.ts | 10 ++++--- .../src/user_storage_client.test.ts | 29 +++++++++++++++++-- .../src/user_storage_client.ts | 12 +++++--- .../src/user_storage_service.test.ts | 2 +- .../browser-mocks/src/client.mock.ts | 2 +- .../user-storage/browser/src/types.ts | 10 +++---- .../browser/src/use_user_storage.test.tsx | 1 + .../packages/user-storage/common/src/types.ts | 8 +++-- .../server-internal/src/routes/index.ts | 5 ++-- .../src/user_storage_client.test.ts | 14 +++++++++ .../src/user_storage_client.ts | 6 ++-- 12 files changed, 81 insertions(+), 28 deletions(-) diff --git a/src/core/packages/user-storage/browser-internal/src/user_storage_api.test.ts b/src/core/packages/user-storage/browser-internal/src/user_storage_api.test.ts index eeb18ab13b618..d2117c34e3f6b 100644 --- a/src/core/packages/user-storage/browser-internal/src/user_storage_api.test.ts +++ b/src/core/packages/user-storage/browser-internal/src/user_storage_api.test.ts @@ -30,14 +30,16 @@ describe('UserStorageApi', () => { expect(result).toEqual({ hidden: ['discover'] }); }); - it('PUTs /internal/user_storage/{key} with a value-wrapped body', async () => { - http.put.mockResolvedValue(undefined); + it('PUTs /internal/user_storage/{key} with a value-wrapped body and returns the validated value', async () => { + const stored = { hidden: ['discover'] }; + http.put.mockResolvedValue({ value: stored }); - await api.set('navigation:layout', { hidden: ['discover'] }); + const result = await api.set('navigation:layout', { hidden: ['discover'] }); expect(http.put).toHaveBeenCalledWith('/internal/user_storage/navigation%3Alayout', { body: JSON.stringify({ value: { hidden: ['discover'] } }), }); + expect(result).toEqual(stored); }); it('DELETEs /internal/user_storage/{key} for remove', async () => { @@ -49,7 +51,7 @@ describe('UserStorageApi', () => { }); it('encodes special characters in keys', async () => { - http.put.mockResolvedValue(undefined); + http.put.mockResolvedValue({ value: 1 }); await api.set('a/b c', 1); diff --git a/src/core/packages/user-storage/browser-internal/src/user_storage_api.ts b/src/core/packages/user-storage/browser-internal/src/user_storage_api.ts index 25c462d21ae1b..e3b004bd88390 100644 --- a/src/core/packages/user-storage/browser-internal/src/user_storage_api.ts +++ b/src/core/packages/user-storage/browser-internal/src/user_storage_api.ts @@ -27,10 +27,12 @@ export class UserStorageApi { return response.value; } - public async set(key: string, value: unknown): Promise { - await this.http.put(`${BASE_PATH}/${encodeURIComponent(key)}`, { - body: JSON.stringify({ value }), - }); + public async set(key: string, value: unknown): Promise { + const response = await this.http.put<{ value: unknown }>( + `${BASE_PATH}/${encodeURIComponent(key)}`, + { body: JSON.stringify({ value }) } + ); + return response.value; } public async remove(key: string): Promise { diff --git a/src/core/packages/user-storage/browser-internal/src/user_storage_client.test.ts b/src/core/packages/user-storage/browser-internal/src/user_storage_client.test.ts index a3619a25f8a68..1f8bb26cb4fd9 100644 --- a/src/core/packages/user-storage/browser-internal/src/user_storage_client.test.ts +++ b/src/core/packages/user-storage/browser-internal/src/user_storage_client.test.ts @@ -96,7 +96,7 @@ describe('UserStorageClient', () => { describe('get$', () => { it('emits the current value immediately and on subsequent updates', async () => { const { client, api } = buildClient({ key: 'first' }); - api.set.mockResolvedValue(undefined); + api.set.mockResolvedValue('second'); const emissions = lastValueFrom(client.get$('key').pipe(take(2), toArray())); @@ -107,7 +107,7 @@ describe('UserStorageClient', () => { it('does not emit for unrelated keys', async () => { const { client, api } = buildClient({ a: 'initial' }); - api.set.mockResolvedValue(undefined); + api.set.mockResolvedValue(99); const first = firstValueFrom(client.get$('a')); await client.set('b', 99); @@ -154,7 +154,7 @@ describe('UserStorageClient', () => { describe('set', () => { it('updates cache and emits on update$ after a successful HTTP call', async () => { const { client, api } = buildClient({ key: 'old' }); - api.set.mockResolvedValue(undefined); + api.set.mockResolvedValue('new'); const updates = firstValueFrom(client.getUpdate$()); @@ -169,6 +169,29 @@ describe('UserStorageClient', () => { }); }); + it('caches the server-validated value rather than the raw input', async () => { + // Simulates a schema that transforms the value (e.g. z.string().trim()). + // The server returns the post-transform value; the browser must cache that. + const { client, api } = buildClient({}); + api.set.mockResolvedValue('trimmed'); + + const stored = await client.set('key', ' trimmed '); + + expect(stored).toBe('trimmed'); + expect(client.get('key')).toBe('trimmed'); + }); + + it('update$ emits the server-validated newValue, not the raw input', async () => { + const { client, api } = buildClient({}); + api.set.mockResolvedValue('normalised'); + + const updates = firstValueFrom(client.getUpdate$()); + + await client.set('key', 'raw input'); + + await expect(updates).resolves.toEqual(expect.objectContaining({ newValue: 'normalised' })); + }); + it('does not mutate cache or emit when the HTTP call fails, and rejects', async () => { const { client, api } = buildClient({ key: 'old' }); api.set.mockRejectedValue(new Error('boom')); diff --git a/src/core/packages/user-storage/browser-internal/src/user_storage_client.ts b/src/core/packages/user-storage/browser-internal/src/user_storage_client.ts index 8d6a9fc6d3b0d..c3015e6e37fdd 100644 --- a/src/core/packages/user-storage/browser-internal/src/user_storage_client.ts +++ b/src/core/packages/user-storage/browser-internal/src/user_storage_client.ts @@ -111,9 +111,12 @@ export class UserStorageClient implements IUserStorageClient { ).pipe(share()); } - public async set(key: string, value: T): Promise { + public async set(key: string, value: T): Promise { + let stored: T; try { - await this.api.set(key, value); + // Cache the server-validated value (post-transform/strip) rather than + // the raw input, so the browser state stays in sync with what ES holds. + stored = (await this.api.set(key, value)) as T; } catch (error) { const err = error instanceof Error ? error : new Error(String(error)); this.httpErrors$.next(err); @@ -121,8 +124,9 @@ export class UserStorageClient implements IUserStorageClient { } const oldValue = this.cache[key]; - this.cache[key] = value; - this.update$.next({ type: 'set', key, newValue: value, oldValue }); + this.cache[key] = stored; + this.update$.next({ type: 'set', key, newValue: stored, oldValue }); + return stored; } public async remove(key: string): Promise { diff --git a/src/core/packages/user-storage/browser-internal/src/user_storage_service.test.ts b/src/core/packages/user-storage/browser-internal/src/user_storage_service.test.ts index c68cfd268c11d..25dda0174048f 100644 --- a/src/core/packages/user-storage/browser-internal/src/user_storage_service.test.ts +++ b/src/core/packages/user-storage/browser-internal/src/user_storage_service.test.ts @@ -37,7 +37,7 @@ describe('UserStorageService', () => { it('writes through to http.put on set', async () => { const service = new UserStorageService(); const deps = buildDeps(); - deps.http.put.mockResolvedValue(undefined); + deps.http.put.mockResolvedValue({ value: { hidden: ['discover'] } }); const client = service.setup(deps); diff --git a/src/core/packages/user-storage/browser-mocks/src/client.mock.ts b/src/core/packages/user-storage/browser-mocks/src/client.mock.ts index 2eb4a36951b80..0b1e4c75f3d59 100644 --- a/src/core/packages/user-storage/browser-mocks/src/client.mock.ts +++ b/src/core/packages/user-storage/browser-mocks/src/client.mock.ts @@ -16,7 +16,7 @@ export const clientMock = (): jest.Mocked => peek: jest.fn(), get: jest.fn(), get$: jest.fn().mockReturnValue(new Subject()), - set: jest.fn().mockResolvedValue(undefined), + set: jest.fn().mockImplementation((_key: string, value: unknown) => Promise.resolve(value)), remove: jest.fn().mockResolvedValue(undefined), getUpdate$: jest.fn().mockReturnValue(new Subject()), getHttpError$: jest.fn().mockReturnValue(new Subject()), diff --git a/src/core/packages/user-storage/browser/src/types.ts b/src/core/packages/user-storage/browser/src/types.ts index 5238509560e51..b712bd3eb51a5 100644 --- a/src/core/packages/user-storage/browser/src/types.ts +++ b/src/core/packages/user-storage/browser/src/types.ts @@ -68,12 +68,12 @@ export interface IUserStorageClient { get$(key: string, defaultValue: T): Observable; /** - * Persists a new value via `PUT /internal/user_storage/{key}`. On success - * the local cache is updated and subscribers to `get$` / `getUpdate$` are - * notified. On HTTP failure the cache is left untouched, the error is - * published to `getHttpError$`, and the returned promise rejects. + * Persists a new value via `PUT /internal/user_storage/{key}`. Returns the + * server-validated form of the value (after any Zod transforms or stripping), + * which is also what gets cached locally. On HTTP failure the cache is left + * untouched, the error is published to `getHttpError$`, and the promise rejects. */ - set(key: string, value: T): Promise; + set(key: string, value: T): Promise; /** * Removes the user override via `DELETE /internal/user_storage/{key}`. diff --git a/src/core/packages/user-storage/browser/src/use_user_storage.test.tsx b/src/core/packages/user-storage/browser/src/use_user_storage.test.tsx index bd14de38832a0..1690eb7abf16a 100644 --- a/src/core/packages/user-storage/browser/src/use_user_storage.test.tsx +++ b/src/core/packages/user-storage/browser/src/use_user_storage.test.tsx @@ -33,6 +33,7 @@ const buildClient = (initial: Record = {}): IUserStorageClient set: jest.fn(async (key: string, value: unknown) => { cache[key] = value; subject$.next({ ...cache }); + return value; }) as IUserStorageClient['set'], remove: jest.fn(async (key: string) => { delete cache[key]; diff --git a/src/core/packages/user-storage/common/src/types.ts b/src/core/packages/user-storage/common/src/types.ts index f16fd85605b0e..42d986aafb363 100644 --- a/src/core/packages/user-storage/common/src/types.ts +++ b/src/core/packages/user-storage/common/src/types.ts @@ -49,8 +49,12 @@ export interface IUserStorageClient { * service to embed values in the initial HTML payload. */ getForInjection(): Promise>; - /** Validate and persist a value for the current user. */ - set(key: string, value: T): Promise; + /** + * Validate and persist a value for the current user. Returns the + * schema-validated form of the value as stored (after any Zod transforms + * or stripping), so callers can use the canonical representation. + */ + set(key: string, value: T): Promise; /** Remove the user override so the key falls back to its default. */ remove(key: string): Promise; } diff --git a/src/core/packages/user-storage/server-internal/src/routes/index.ts b/src/core/packages/user-storage/server-internal/src/routes/index.ts index 7fac8847e3c5f..ba0b9c0ed25e8 100644 --- a/src/core/packages/user-storage/server-internal/src/routes/index.ts +++ b/src/core/packages/user-storage/server-internal/src/routes/index.ts @@ -98,8 +98,9 @@ export const registerRoutes = ({ router, definitions, logger }: RegisterRoutesPa const { key } = request.params; const { value } = request.body; + let validated: unknown; try { - await client.set(key, value); + validated = await client.set(key, value); } catch (err) { if (err instanceof z.ZodError) { return response.badRequest({ body: { message: `Validation failed: ${err.message}` } }); @@ -110,7 +111,7 @@ export const registerRoutes = ({ router, definitions, logger }: RegisterRoutesPa throw err; } - return response.ok({ body: {} }); + return response.ok({ body: { value: validated } }); } ); diff --git a/src/core/packages/user-storage/server-internal/src/user_storage_client.test.ts b/src/core/packages/user-storage/server-internal/src/user_storage_client.test.ts index a483e8163bf05..fd8560375b56c 100644 --- a/src/core/packages/user-storage/server-internal/src/user_storage_client.test.ts +++ b/src/core/packages/user-storage/server-internal/src/user_storage_client.test.ts @@ -193,3 +193,17 @@ describe('UserStorageClient.getForInjection()', () => { await expect(client.getForInjection()).rejects.toThrow('boom'); }); }); + +describe('UserStorageClient.set()', () => { + it('returns the schema-validated value', async () => { + const definitions = new Map([ + ['space:a', { schema: z.string().trim(), defaultValue: '', scope: 'space' }], + ]); + const { client, savedObjectsClient } = buildClient(definitions); + savedObjectsClient.update.mockResolvedValue({} as any); + + const result = await client.set('space:a', ' hello '); + + expect(result).toBe('hello'); + }); +}); diff --git a/src/core/packages/user-storage/server-internal/src/user_storage_client.ts b/src/core/packages/user-storage/server-internal/src/user_storage_client.ts index 7d2b8235a358c..e67096612aec6 100644 --- a/src/core/packages/user-storage/server-internal/src/user_storage_client.ts +++ b/src/core/packages/user-storage/server-internal/src/user_storage_client.ts @@ -117,9 +117,9 @@ export class UserStorageClient implements IUserStorageClient { return result; } - async set(key: string, value: T): Promise { + async set(key: string, value: T): Promise { const definition = this.assertRegistered(key); - const validated = definition.schema.parse(value); + const validated = definition.schema.parse(value) as T; const soType = this.getSoType(definition); this.logger.debug(`Setting userStorage key [${key}] (scope: ${definition.scope})`); @@ -149,6 +149,8 @@ export class UserStorageClient implements IUserStorageClient { throw err; } } + + return validated; } async remove(key: string): Promise { From bc289b42b8790ef1a5a1adf62e616562c0ed4114 Mon Sep 17 00:00:00 2001 From: Timothy Sullivan Date: Fri, 22 May 2026 12:25:35 -0700 Subject: [PATCH 39/41] disallow undefined as allowed value in registration --- src/core/packages/user-storage/README.mdx | 2 + .../src/user_storage_service.test.ts | 38 +++++++++++++++++++ .../src/user_storage_service.ts | 7 ++++ 3 files changed, 47 insertions(+) diff --git a/src/core/packages/user-storage/README.mdx b/src/core/packages/user-storage/README.mdx index 0447bf799f060..5bc889ad58934 100644 --- a/src/core/packages/user-storage/README.mdx +++ b/src/core/packages/user-storage/README.mdx @@ -107,6 +107,8 @@ export class MyPlugin implements Plugin { Each key may only be registered once across all plugins. Duplicate registrations throw at boot. +Registration also rejects top-level Zod schemas that accept **`null`** (reserved as the removal tombstone in saved objects) or **`undefined`** (reserved for “no cached value” on the client and unreliable over JSON, since `JSON.stringify({ value: undefined })` drops the `value` key). + ## Reading and writing on the server `asScoped(request)` returns a client bound to the authenticated user behind a `KibanaRequest`. It returns `null` when the request has no `profile_uid` (typically API-key authentication or anonymous pages); always check. diff --git a/src/core/packages/user-storage/server-internal/src/user_storage_service.test.ts b/src/core/packages/user-storage/server-internal/src/user_storage_service.test.ts index eb1e2f7f92ace..7981859e590c9 100644 --- a/src/core/packages/user-storage/server-internal/src/user_storage_service.test.ts +++ b/src/core/packages/user-storage/server-internal/src/user_storage_service.test.ts @@ -79,4 +79,42 @@ describe('UserStorageService.register()', () => { register({ 'test:key': { schema: z.string(), defaultValue: '', scope: 'global' } }) ).not.toThrow(); }); + + it('throws when the schema accepts undefined (reserved for uncached reads)', () => { + const register = buildRegister(); + expect(() => + register({ + 'test:key': { + schema: z.string().optional(), + defaultValue: 'fallback', + scope: 'space', + }, + }) + ).toThrow(/must not accept undefined/); + }); + + it('throws when a z.undefined() schema is registered', () => { + const register = buildRegister(); + expect(() => + register({ + 'test:key': { schema: z.undefined(), defaultValue: undefined as any, scope: 'space' }, + }) + ).toThrow(/must not accept undefined/); + }); + + it('throws when a z.unknown() schema is registered (accepts undefined)', () => { + const register = buildRegister(); + expect(() => + register({ + 'test:key': { schema: z.unknown(), defaultValue: 0 as any, scope: 'space' }, + }) + ).toThrow(/must not accept undefined/); + }); + + it('accepts a schema that explicitly rejects undefined (z.string() is required by default)', () => { + const register = buildRegister(); + expect(() => + register({ 'test:key': { schema: z.string(), defaultValue: 'x', scope: 'global' } }) + ).not.toThrow(); + }); }); diff --git a/src/core/packages/user-storage/server-internal/src/user_storage_service.ts b/src/core/packages/user-storage/server-internal/src/user_storage_service.ts index 0d228144620e7..1915e4f3c24e3 100644 --- a/src/core/packages/user-storage/server-internal/src/user_storage_service.ts +++ b/src/core/packages/user-storage/server-internal/src/user_storage_service.ts @@ -74,6 +74,13 @@ export class UserStorageService { `userStorage key [${key}] has a defaultValue that does not match its schema: ${message}` ); } + if (definition.schema.safeParse(undefined).success) { + throw new Error( + `userStorage key [${key}] schema must not accept undefined. ` + + `undefined is reserved for absent cache entries and JSON.stringify omits ` + + `undefined properties, so it cannot be a reliable stored value.` + ); + } if (definition.schema.safeParse(null).success) { throw new Error( `userStorage key [${key}] schema must not accept null. ` + From 0a19ba2e13138ed880543c0cbab8a5097e514442 Mon Sep 17 00:00:00 2001 From: Timothy Sullivan Date: Fri, 22 May 2026 12:30:44 -0700 Subject: [PATCH 40/41] fix type: `set` doesn't return Promise --- src/core/packages/user-storage/browser/src/use_user_storage.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/packages/user-storage/browser/src/use_user_storage.ts b/src/core/packages/user-storage/browser/src/use_user_storage.ts index 5d2a37036eacf..94b0f87cf158b 100644 --- a/src/core/packages/user-storage/browser/src/use_user_storage.ts +++ b/src/core/packages/user-storage/browser/src/use_user_storage.ts @@ -31,7 +31,7 @@ export const useUserStorageClient = (): IUserStorageClient => { return client; }; -export type UserStorageSetter = (newValue: T) => Promise; +export type UserStorageSetter = (newValue: T) => Promise; /** * Subscribes to a single user-storage key and returns a `[value, setter]` From c46bbf9ca2cb4bb9772a93e1028aee74c0e2b19a Mon Sep 17 00:00:00 2001 From: Timothy Sullivan Date: Fri, 22 May 2026 13:34:20 -0700 Subject: [PATCH 41/41] update limits --- packages/kbn-optimizer/limits.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index f574f2754cf20..892a99934dca9 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -29,7 +29,7 @@ pageLoadAssetSize: contentConnectors: 33014 contentManagement: 8350 controls: 10300 - core: 547151 + core: 548600 cps: 9209 crossClusterReplication: 12662 customIntegrations: 11715