Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Core: Add UniversalStore API to sync state/events between multiple environments #30445

Open
wants to merge 35 commits into
base: next
Choose a base branch
from

Conversation

JReinhold
Copy link
Contributor

@JReinhold JReinhold commented Feb 3, 2025

Works on #30201

What I did

Added initial implementation of the UniversalStore.

See this overview of tests and note the skipped ones are todo (potentially in a follow-up):

image

Checklist for Contributors

Testing

The changes in this PR are covered in the following automated tests:

  • stories
  • unit tests
  • integration tests
  • end-to-end tests

Manual testing

To test this out manually you can make the following changes to the UI Storybook:

  1. add './my-preset.ts to the list of addons in code/.storybook/main.ts
  2. add code/.storybook/my-preset.ts with:
import { UniversalStore } from 'storybook/internal/core-server';

type State = {
  count: number;
  otherCount: number;
  text: string;
};

type Event = {
  type: 'TRIGGER_COOL_STUFF';
  payload: {
    amount: number;
  };
};

const serverStore = UniversalStore.create<State, Event>({
  id: 'my-uni-store',
  leader: true,
  initialState: { count: 0, otherCount: 10, text: 'hello' },
});

serverStore.untilReady().then(() => {
  console.log('💪💪💪 PRESET IS READY');
});

serverStore.onStateChange((state) => {
  console.log('✅✅✅ STATE CHANGED IN PRESET', state);
});

serverStore.subscribe('TRIGGER_COOL_STUFF', (event) => {
  console.log('🎉🎉🎉 TRIGGER COOL STUFF!!!', event.payload.amount);
});
  1. modify code/.storybook/manager.tsx to be:
import React from 'react';

import { WithTooltip } from 'storybook/internal/components';
import { UniversalStore, addons, types, useUniversalStore } from 'storybook/internal/manager-api';

import { startCase } from 'es-toolkit/compat';

type State = {
  count: number;
  otherCount: number;
  text: string;
};

type Event = {
  type: 'TRIGGER_COOL_STUFF';
  payload: {
    amount: number;
  };
};

const managerStore =
  globalThis.CONFIG_TYPE === 'PRODUCTION'
    ? UniversalStore.create<State, Event>({
        id: 'my-uni-store',
        leader: true,
        initialState: {
          count: 0,
          otherCount: 0,
          text: 'manager is leader now 😈',
        },
      })
    : UniversalStore.create<State, Event>({
        id: 'my-uni-store',
      });

managerStore.onStateChange((state) => {
  console.log('✅✅✅ STATE CHANGED IN MANAGER', state);
});

managerStore.untilReady().then(() => {
  console.log('💪💪💪 MANAGER IS READY');
});

const Panel = () => {
  const [state, setState] = useUniversalStore(managerStore);
  const renderCountRef = React.useRef(0);
  React.useEffect(() => {
    renderCountRef.current++;
  });
  return (
    <div>
      <pre>
        <code>{JSON.stringify(state, null, 2)}</code>
      </pre>
      <button onClick={() => setState((s) => ({ ...s, count: s.count + 1 }))}>Increment</button>
      <input
        value={state?.text}
        onChange={(e) => setState((s) => ({ ...s, text: e.target.value }))}
      />
      <button
        onClick={() => managerStore.send({ type: 'TRIGGER_COOL_STUFF', payload: { amount: 20 } })}
      >
        Cool Trigger
      </button>
      <pre>
        <code>Renders: {renderCountRef.current}</code>
      </pre>
    </div>
  );
};

const Tool = () => {
  const [state, setState] = useUniversalStore(managerStore, (s) => s?.count);
  const renderCountRef = React.useRef(0);
  React.useEffect(() => {
    renderCountRef.current++;
  });

  return (
    <WithTooltip
      placement="top"
      tooltip={() => {
        return (
          <div>
            <pre>
              <code>{JSON.stringify(state, null, 2)}</code>
            </pre>
            <button onClick={() => setState((s) => ({ ...s, count: s.count + 1 }))}>
              Increment
            </button>
            <pre>
              <code>Renders: {renderCountRef.current}</code>
            </pre>
          </div>
        );
      }}
    >
      UniversalStore Tester
    </WithTooltip>
  );
};

addons.register('my-uni-addon', (api) => {
  console.log('LOG: registrering addon in manager');

  addons.add('my-uni-addon-panel', {
    title: 'UniversalStore Tester',
    type: types.PANEL,
    render: () => {
      return <Panel />;
    },
  });

  addons.add('my-uni-addon-toolbar', {
    title: 'UniversalStore Tester',
    type: types.TOOL,
    render: () => {
      return <Tool />;
    },
  });
});

addons.setConfig({
  sidebar: {
    renderLabel: ({ name, type }) => (type === 'story' ? name : startCase(name)),
  },
});
  1. add the following to code/.storybook/preview.tsx
import { UniversalStore, useUniversalStore } from 'storybook/internal/preview-api';

...

type State = {
  count: number;
  otherCount: number;
  text: string;
};

type Event = {
  type: 'TRIGGER_COOL_STUFF';
  payload: {
    amount: number;
  };
};

console.log('LOG: creating preview store');
const previewStore = UniversalStore.create<State, Event>({
  id: 'my-uni-store',
  debug: true,
});
previewStore.onStateChange((state) => {
  console.log('🥳🥳🥳 STATE CHANGED IN PREVIEW', state);
});

previewStore.untilReady().then(() => {
  console.log('💪💪💪 PREVIEW IS READY');
});

...

export const decorators = [
  (Story) => {
    const [state, setState] = useUniversalStore(previewStore, (s) => s?.text);
    const renderCountRef = React.useRef(0);
    React.useEffect(() => {
      renderCountRef.current++;
    });
    return (
      <div>
        <pre>
          <code>{JSON.stringify(state, null, 2)}</code>
        </pre>
        <button onClick={() => setState((s) => ({ ...s, count: s.count + 1 }))}>Increment</button>
        <input value={state} onChange={(e) => setState((s) => ({ ...s, text: e.target.value }))} />
        <button
          onClick={() => previewStore.send({ type: 'TRIGGER_COOL_STUFF', payload: { amount: 20 } })}
        >
          Cool Trigger
        </button>
        <pre>
          <code>Renders: {renderCountRef.current}</code>
        </pre>

        <Story />
      </div>
    );
  },
...

This section is mandatory for all contributions. If you believe no manual test is necessary, please state so explicitly. Thanks!

Documentation

  • Add or update documentation reflecting your changes
  • If you are deprecating/removing a feature, make sure to update
    MIGRATION.MD

Checklist for Maintainers

  • When this PR is ready for testing, make sure to add ci:normal, ci:merged or ci:daily GH label to it to run a specific set of sandboxes. The particular set of sandboxes can be found in code/lib/cli-storybook/src/sandbox-templates.ts

  • Make sure this PR contains one of the labels below:

    Available labels
    • bug: Internal changes that fixes incorrect behavior.
    • maintenance: User-facing maintenance tasks.
    • dependencies: Upgrading (sometimes downgrading) dependencies.
    • build: Internal-facing build tooling & test updates. Will not show up in release changelog.
    • cleanup: Minor cleanup style change. Will not show up in release changelog.
    • documentation: Documentation only changes. Will not show up in release changelog.
    • feature request: Introducing a new feature.
    • BREAKING CHANGE: Changes that break compatibility in some way with current major version.
    • other: Changes that don't fit in the above categories.

🦋 Canary release

This PR does not have a canary release associated. You can request a canary release of this pull request by mentioning the @storybookjs/core team here.

core team members can create a canary release here or locally with gh workflow run --repo storybookjs/storybook canary-release-pr.yml --field pr=<PR_NUMBER>

name before after diff z %
createSize 0 B 0 B 0 B - -
generateSize 78 MB 78 MB 0 B 1.34 0%
initSize 131 MB 131 MB 164 kB 3.66 0.1%
diffSize 53 MB 53.2 MB 164 kB 5.46 0.3%
buildSize 7.17 MB 7.22 MB 47.9 kB 124.51 0.7%
buildSbAddonsSize 1.85 MB 1.87 MB 25.3 kB 260.45 1.4%
buildSbCommonSize 195 kB 195 kB 0 B - 0%
buildSbManagerSize 1.86 MB 1.88 MB 14 kB 2453.13 0.7%
buildSbPreviewSize 0 B 0 B 0 B - -
buildStaticSize 0 B 0 B 0 B - -
buildPrebuildSize 3.91 MB 3.95 MB 39.4 kB 418.04 1%
buildPreviewSize 3.26 MB 3.27 MB 8.48 kB 26.63 0.3%
testBuildSize 0 B 0 B 0 B - -
testBuildSbAddonsSize 0 B 0 B 0 B - -
testBuildSbCommonSize 0 B 0 B 0 B - -
testBuildSbManagerSize 0 B 0 B 0 B - -
testBuildSbPreviewSize 0 B 0 B 0 B - -
testBuildStaticSize 0 B 0 B 0 B - -
testBuildPrebuildSize 0 B 0 B 0 B - -
testBuildPreviewSize 0 B 0 B 0 B - -
name before after diff z %
createTime 9.1s 8.9s -194ms -1.61 -2.2%
generateTime 19.4s 18.2s -1s -162ms -0.87 -6.4%
initTime 13.1s 11.6s -1s -465ms -0.8 -12.6%
buildTime 10.6s 8.3s -2s -305ms -0.54 -27.8%
testBuildTime 0ms 0ms 0ms - -
devPreviewResponsive 6.2s 6.4s 248ms 0.52 3.8%
devManagerResponsive 4.7s 4.7s -8ms 0.52 -0.2%
devManagerHeaderVisible 1s 941ms -105ms 1.14 -11.2%
devManagerIndexVisible 1s 1s -18ms 1.47 -1.7%
devStoryVisibleUncached 4.3s 4.3s -44ms 0.78 -1%
devStoryVisible 1s 1s -36ms 1.44 -3.5%
devAutodocsVisible 847ms 949ms 102ms 1.72 🔺10.7%
devMDXVisible 855ms 821ms -34ms 0.94 -4.1%
buildManagerHeaderVisible 913ms 940ms 27ms 0.44 2.9%
buildManagerIndexVisible 1s 1s 30ms 0.5 2.8%
buildStoryVisible 829ms 909ms 80ms 0.44 8.8%
buildAutodocsVisible 788ms 962ms 174ms 0.08 18.1%
buildMDXVisible 818ms 685ms -133ms 0.47 -19.4%

Greptile Summary

Introduces a new UniversalStore API for synchronizing state and events across different Storybook environments (server, manager UI, preview) with a leader-follower pattern.

  • Added code/core/src/shared/universal-store/index.ts implementing core UniversalStore functionality with state sync and event handling
  • Added code/core/src/shared/universal-store/use-universal-store-manager.ts and use-universal-store-preview.ts with environment-specific React hooks
  • Added comprehensive test coverage in code/core/src/shared/universal-store/index.test.ts for store creation, state management and event handling
  • Added type definitions in code/core/src/shared/universal-store/types.ts for type-safe state and event handling
  • Exported API as experimental features through manager-api, preview-api and core-server modules

Copy link

nx-cloud bot commented Feb 3, 2025

View your CI Pipeline Execution ↗ for commit df59bc4.

Command Status Duration Result
nx run-many -t build --parallel=3 ✅ Succeeded 1m 50s View ↗

☁️ Nx Cloud last updated this comment at 2025-02-04 14:51:55 UTC

@JReinhold JReinhold marked this pull request as ready for review February 3, 2025 10:28
Copy link
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

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

17 file(s) reviewed, 19 comment(s)
Edit PR Review Bot Settings | Greptile

code/core/src/channels/index.ts Show resolved Hide resolved
code/core/src/channels/index.ts Show resolved Hide resolved
code/core/src/channels/index.ts Show resolved Hide resolved
code/core/src/shared/universal-store/types.ts Outdated Show resolved Hide resolved
code/core/tsconfig.json Show resolved Hide resolved
@storybook-pr-benchmarking
Copy link

storybook-pr-benchmarking bot commented Feb 3, 2025

Package Benchmarks

Commit: df59bc4, ran on 4 February 2025 at 14:57:39 UTC

The following packages have significant changes to their size or dependencies:

@storybook/core

Before After Difference
Dependency count 54 54 0
Self size 19.05 MB 19.22 MB 🚨 +164 KB 🚨
Dependency size 14.44 MB 14.44 MB 0 B
Bundle Size Analyzer Link Link

storybook

Before After Difference
Dependency count 55 55 0
Self size 22 KB 22 KB 0 B
Dependency size 33.50 MB 33.66 MB 🚨 +164 KB 🚨
Bundle Size Analyzer Link Link

sb

Before After Difference
Dependency count 56 56 0
Self size 1 KB 1 KB 0 B
Dependency size 33.52 MB 33.68 MB 🚨 +164 KB 🚨
Bundle Size Analyzer Link Link

@storybook/cli

Before After Difference
Dependency count 388 388 0
Self size 503 KB 503 KB 0 B
Dependency size 75.37 MB 75.54 MB 🚨 +164 KB 🚨
Bundle Size Analyzer Link Link

@storybook/codemod

Before After Difference
Dependency count 277 277 0
Self size 617 KB 617 KB 0 B
Dependency size 65.45 MB 65.62 MB 🚨 +164 KB 🚨
Bundle Size Analyzer Link Link

create-storybook

Before After Difference
Dependency count 113 113 0
Self size 1.11 MB 1.11 MB 0 B
Dependency size 42.63 MB 42.79 MB 🚨 +164 KB 🚨
Bundle Size Analyzer Link Link

code/core/src/shared/universal-store/index.ts Outdated Show resolved Hide resolved
code/core/src/shared/universal-store/index.ts Outdated Show resolved Hide resolved
code/core/src/shared/universal-store/index.ts Outdated Show resolved Hide resolved
code/core/src/shared/universal-store/index.ts Outdated Show resolved Hide resolved
code/core/src/shared/universal-store/index.ts Outdated Show resolved Hide resolved
@JReinhold
Copy link
Contributor Author

@valentinpalkovic made some changes:

image
image

  1. if a second leader is created, the first leader will now log an error, and put all instances in an error state. Nothing will throw, as the only way to implement that was with timeouts, causing everything to slow down. I think the error log is the right trade off.
  2. pulled selectors out of getState and onStateChange, as they aren't really necessary there, they are only useful in the hooks that are reactive. so the hooks implements the selector directly now, which also made all the typings a lot simpler.
  3. LEADER_CREATED and FOLLOWER_CREATED events are now emitted
  4. Added tests for useUniversalStore manager hook. the preview hooks are pretty bad to test so I won't bother.

id: string;
leader?: false;
debug?: boolean;
initialState?: undefined;
Copy link
Contributor

Choose a reason for hiding this comment

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

I am not sure, but isn't this more correct:

initialState?: never

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants