synapse-web-monorepo Architectural and Styling Proposals

synapse-web-monorepo Architectural and Styling Proposals

This document contains a few distinct proposals that aim to improve our codebase, code quality, and developer experience.

Proposal 1: New packages

The synapse-react-client package has become a catch-all for numerous components, hooks, and other logic. There is some subset of this code that can be distinctly extracted to isolate logic and tests, and describe clear boundaries for different units of code.

This proposal suggests extracting two new packages, react-base-components and synapse-queries, and creating one new app, storybook, which will compose multiple storybooks (i.e. there is just one publishable Storybook web artifact that contains individual Storybooks used in react-base-components and synapse-react-client.

Illustration:

synapse-web-monorepo ├─ apps/ | └─ storybook # (new) Application that composes multiple local storybooks └─ packages/ ├─ react-base-components/ # (new) Base UI components & theming for Synapse/Portals/Any other React app └─ synapse-queries/ # (new) React Query-related hooks and exports for Synapse (existing packages are omitted for brevity)

The react-base-components package will provide generic, reusable UI elements (such as Button, TextArea, DialogBase) and theming utilities. It also provides a layer of abstraction over MUI elements, which would limit the impact of a MUI breaking change, or simplify the process of replacing a single component with a non-MUI alternative.

The synapse-queries package would be lifted from the existing synapse-react-client/src/synapse-queries folder. This package would isolate the react-query hooks and invalidation logic for the Synapse API.

The storybook app will allow us to separate multiple component libraries while preserving the ability to publish single Storybook app.

Proposal 2: Reorganize synapse-react-client

synapse-react-client (and other projects) could be better organized to make it easier for both engineers & agents to find, reuse, and create code. Moreover, following a better organizational pattern can facilitate creating more resilient, reusable, and testable UI components. This proposal describes a new organizational structure for synapse-react-client. This new structure provides implicit guidance on how to split business logic in a way that promotes reuse and testability. It is also based on other patterns that are widely adopted, which can be a useful reference as well as an advantage when using agents that are trained on repositories with similar structure.

Within synapse-react-client, we can reorganize the library to have the following structure. This is based heavily on Bulletproof React, but has been modified to fit our needs and to minimize the burden needed to migrate from our current directory structure.

src/ ├─ assets/ # assets folder can contain all the static files such as images, fonts, etc. ├─ components # shared components used across the entire application ├─ features/ # (new) feature based modules ├─ hooks/ # (new) shared hooks used across the entire application ├─ lib/ # (new) reusable libraries preconfigured for the application (example: ROR API Client) ├─ mocks/ # shared mocks ├─ stores/ # (new) global state stores ├─ testing/ # test utilities ├─ types/ # (new) shared types used across the application └─ utils/ # shared utility functions (note that some existing directories are omitted for brevity)

The features directory would contain many features, such as:

src/features/feature-category/my-feature ├─ assets # assets folder can contain all the static files for a specific feature ├─ components # components used only within this specific feature ├─ hooks/ # hooks scoped to a specific feature ├─ mocks/ # shared mocks ├─ stores/ # state stores for a specific feature ├─ types/ # typescript types used within the feature └─ utils/ # utility functions for a specific feature Not all directories will be used for every feature.

A feature should not import anything from another feature directory; if this is needed, then you can refactor that aspect of your feature back into the shared src/components, src/hooks, or src/utils directories. We could enforce this using ESLint rules.

Non-changes:

  • Continue co-locating tests and stories with React component files

Key differences with the Bulletproof React project structure:

  • Bulletproof React describes a repository with only one app. Our monorepo will continue to have more than one 'app'. We will not change this (but we may refactor each app such that it follows the bulletproof pattern described in synapse-react-client)

  • Feature directories in synapse-react-client/features may be nested (e.g. features/entity/entity-page). Synapse & Portals contain a rich feature set that requires another layer of organization

    • src/components are commonly reused in different contexts. For example:

      • EntityFinder

      • The generic AclEditor

      • MarkdownSynapse

    • Features typically are a collection of components that fit somewhere into Synapse/a portal application. They typically would not be reused within synapse-react-client. Note that features themselves can have their own “private” components subdirectory. Examples:

      • ReviewerDashboard governance controls.

      • OAuthClientManagement

      • EvaluationEditor

  • We will not have api directories for the Synapse API. The Synapse API will be captured in different packages (i.e. @sage-bionetworks/synapse-client). It may be acceptable to create an API folder when interacting with a 3rd party API, but it might be preferable to make this a separate lib/package.

Proposal 3: Styling

This section contains two proposals that can be independently approved/rejected.

In synapse-web-monorepo, we currently use a combination of CSS-in-JS (MUI styled components) and multiple SCSS stylesheets that are compiled into one large stylesheet. This has a number of drawbacks:

  • The styled components approach locks us into the current major version of MUI. Upgrading the major version of MUI can impact many dispersed styles defined within components. Our last major version upgrade required a large code change and a significant amount of work to manage breaking changes in MUI's styled components API.

  • This approach tightly couples unrelated styles with business logic, which makes it harder to re-use components.

  • Our SCSS stylesheets use SCSS variables, which cannot be used outside of CSS

  • One large stylesheet increases the risk of style collisions, which is undesirable in a component-driven architecture.

To address these, we propose the following changes:

  • Create our own set of custom CSS variables, and opt in to using MUI CSS Variables. Prefer using CSS variables over compile-time SCSS variables.

    • CSS variables are a well-supported native feature that gives us a standard, native way to inject values into styles. CSS variables can be used in pre-processed SCSS, static CSS, or referenced in CSS-in-JS. Overrides can also be created within a selector.

  • Adopt CSS/SCSS Modules. Prefer using CSS/SCSS modules over styled components (our current MUI setup), or one giant set of combined stylesheets (our current SCSS setup).

    • CSS Modules allows us to provide modular styles that are separated from other component styles, and are decoupled from UI business logic.

    • Modular design would allow us to more easily create multiple styled variants for a single component (style tweaks for Portal components seem to be becoming more frequent)

    • Limiting usage of styled components would not prevent using CSS-in-JS, but CSS-in-JS should be used sparingly (only when necessary, e.g. some operation triggered in JavaScript causes a style change)

Migration paths for existing styles

Adding another style pattern will introduce the complexity of the code base. To make it worth adopting, we have to make an effort to migrate our old style patterns away to adopt this pattern.

I argue that migrating to component-based SCSS modules has natural migration paths for all of our patterns used before, so the scope of this effort is reasonable to accept. Below, I outline each of the major patterns we are currently using, and describe a minimal migration path to achieve consistency in our styles.

Pattern 1: SCSS Variables

SCSS Variables can be replaced by CSS variables.

Example
$colors: ( 'success': #32a330, ) !default; button { color: map.get($colors, 'success'); }
:root { --synapse-theme-color-success: #32a330; } button { color: var(--synapse-theme-color-success); }

Pattern 2: MUI Theme Variables

MUI Theme Variables can be replaced by MUI CSS Variables or our own CSS Variables

Example: Static → SCSS Module
function MyComponent() { const theme = useTheme(); const color = theme.palette.primary.main; return <Box sx={{color}} /> }
import styles from './MyComponent.module.scss' function MyComponent() { return <Box className={styles.root} /> }
.root { color: var(--mui-palette-primary-main); /* or var(--synapse-theme-color-success) */ }
Example: Dynamic
function MyComponent({ flag }) { const theme = useTheme(); let color = theme.palette.primary.main; if (flag) { color = theme.palette.secondary.main; } return <Box sx={{color}} /> }
function MyComponent({ flag }) { const theme = useTheme(); // Alternatively, we could create our own JS bindings for our custom theme variables let color = theme.vars.palette.primary.main; if (flag) { color = theme.vars.palette.secondary.main; } return <Box sx={{color}} /> }

Pattern 3: Styled MUI Component

A CSS-in-JS styled component can be converted to use (S)CSS modules by copying the styles to the module.scss file. You may notice that this pattern still allows us to create multiple styled variants of a single component.

Example
export const InlineBadge: StyledComponent<BadgeProps> = styled(Badge, { label: 'InlineBadge', })(({ theme }) => ({ position: 'static', justifyContent: 'flex-end', alignItems: 'center', '.MuiBadge-badge': { minWidth: 'unset', position: 'relative', transform: 'none', }, }))
import styles from './InlineBadge.module.scss' function InlineBadge(props) { return <Badge className={styles.root} /> }
.root { position: static; justifyContent: flex-end; alignItems: center; .MuiBadge-badge: { minWidth: unset; position: relative; transform: none; } }

Pattern 4: Component-Scoped Stylesheet Part of Global Stylesheet

Instead of importing our component-scoped stylesheets in the src/style/components/_all.scss file, the stylesheet can be imported as a modular stylesheet. Classes may be renamed to be less-specific, since the bundler will generate class names that ensure there are no collisions.

Example
export function FacetChip(props: FacetChipProps) { const { isChecked, onClick, children } = props return ( <button className={`Chip ${isChecked ? 'Checked' : ''}`} onClick={onClick}> {children} <IconSvg icon={isChecked ? 'check' : 'add'} sx={{ width: '14px', paddingLeft: '0.2rem', }} ></IconSvg> </button> ) }
/* This file is imported by the _all.scss file */ .Chip { border-radius: SRC.$button-border-radius; padding: 4px 6px; margin: 4px 4px 4px 0; &:hover { background-color: map.get(SRC.$secondary-color-palette, 100); } &:active { background-color: map.get(SRC.$secondary-color-palette, 200); } } .Checked { color: #ffffff; background-color: SRC.$secondary-action-color; &:hover { background-color: map.get(SRC.$secondary-color-palette, 700); } &:active { background-color: map.get(SRC.$secondary-color-palette, 600); } }
import styles from './FacetChip.module.scss' export function FacetChip(props: FacetChipProps) { const { isChecked, onClick, children } = props return ( <button className={`${styles.Chip} ${isChecked ? styles.Checked : ''}`} onClick={onClick}> {children} <IconSvg icon={isChecked ? 'check' : 'add'} sx={{ width: '14px', paddingLeft: '0.2rem', }} ></IconSvg> </button> ) }
.Chip { border-radius: SRC.$button-border-radius; padding: 4px 6px; margin: 4px 4px 4px 0; &:hover { background-color: map.get(SRC.$secondary-color-palette, 100); } &:active { background-color: map.get(SRC.$secondary-color-palette, 200); } } .Checked { color: #ffffff; background-color: SRC.$secondary-action-color; &:hover { background-color: map.get(SRC.$secondary-color-palette, 700); } &:active { background-color: map.get(SRC.$secondary-color-palette, 600); } }