Hyoban

Hyoban

Don’t do what you should do, do you want.
x
github
email
telegram
follow

Theme system, Local-First

In the second week, I briefly defined the theme of the app (which is mainly about colors) and set up the app's database to build the display interface using the data.

Theme System#

When I initialized the project, I chose Tamagui, but when I started customizing the theme system, I found the documentation confusing 😵‍💫. In addition, its comprehensive style made me switch to Unistyles.

Its theme system is just a regular object, and all I need to do is pass my favorite Radix Color to it. Unlike Tailwind's color scheme, it has designed corresponding dark colors for each color, making it very simple to support dark themes.

export const lightTheme = {
  colors: {
    ...accent,
    ...accentA,
  },
} as const

export const darkTheme = {
  colors: {
    ...accentDark,
    ...accentDarkA,
  },
} as const

Because there are quite a few colors to pass, it's easy to forget to add the corresponding theme to the dark theme. This can be constrained by type checking. Refer to the article How to test your types.

type Expect<T extends true> = T
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends <T>() => T extends Y ? 1 : 2 ? true : false
type _ExpectLightAndDarkThemesHaveSameKeys = Expect<Equal<
  keyof typeof lightTheme.colors,
  keyof typeof darkTheme.colors
>>

In addition, you can easily modify the theme using its runtime, making it very simple to write dynamic theme switching like the one below.

UnistylesRuntime.updateTheme(
  UnistylesRuntime.themeName,
  oldTheme => ({
    ...oldTheme,
    colors: {
      ...oldTheme.colors,
      ...accent,
      ...accentA,
      ...accentDark,
      ...accentDarkA,
    },
  }),
)

Local First#

If it's a web page, it's reasonable not to do Local First. As an environment that can run SQLite, there's no reason why the app can't be opened in an offline environment. Currently, my idea is for the app to mainly interact with the local database and use network requests for data synchronization.

Regarding the technology stack, I chose drizzle without hesitation for the following reasons:

  1. The server-side I'm currently following is also using it, and I can even copy many table definitions.
  2. Compared to libraries like Prisma that use code generation for type definitions, I prefer to write table definitions in ts to have instant type refreshing.
  3. The integration recommended by Expo official documentation with Expo SQLite is drizzle, while Prisma integration is still in the Early Access stage.

Expo SQLite provides the addDatabaseChangeListener interface, which allows us to get the latest data from the database in real-time, and drizzle provides the useLiveQuery wrapper. However, currently its hook has an issue with correctly handling the useEffect dependency array:

In addition, we need to cache the results; otherwise, there will be many unnecessary database queries when switching pages. So, we use swr to wrap our own hook.

import { is, SQL, Subquery } from 'drizzle-orm'
import type { AnySQLiteSelect } from 'drizzle-orm/sqlite-core'
import { getTableConfig, getViewConfig, SQLiteTable, SQLiteView } from 'drizzle-orm/sqlite-core'
import { SQLiteRelationalQuery } from 'drizzle-orm/sqlite-core/query-builders/query'
import { addDatabaseChangeListener } from 'expo-sqlite/next'
import type { Key } from 'swr'
import type { SWRSubscriptionOptions } from 'swr/subscription'
import useSWRSubscription from 'swr/subscription'

export function useQuerySubscription<
  T extends
  | Pick<AnySQLiteSelect, '_' | 'then'>
  | SQLiteRelationalQuery<'sync', unknown>,
  SWRSubKey extends Key,
>(
  query: T,
  key: SWRSubKey,
) {
  function subscribe(_key: SWRSubKey, { next }: SWRSubscriptionOptions<Awaited<T>, any>) {
    const entity = is(query, SQLiteRelationalQuery)
    // @ts-expect-error
      ? query.table
      // @ts-expect-error
      : (query as AnySQLiteSelect).config.table

    if (is(entity, Subquery) || is(entity, SQL)) {
      next(new Error('Selecting from subqueries and SQL are not supported in useQuerySubscription'))
      return
    }

    query.then((data) => { next(undefined, data) })
      .catch((error) => { next(error) })

    let listener: ReturnType<typeof addDatabaseChangeListener> | undefined

    if (is(entity, SQLiteTable) || is(entity, SQLiteView)) {
      const config = is(entity, SQLiteTable) ? getTableConfig(entity) : getViewConfig(entity)

      let queryTimeout: NodeJS.Timeout | undefined
      listener = addDatabaseChangeListener(({ tableName }) => {
        if (config.name === tableName) {
          if (queryTimeout) {
            clearTimeout(queryTimeout)
          }
          queryTimeout = setTimeout(() => {
            query.then((data) => { next(undefined, data) })
              .catch((error) => { next(error) })
          }, 0)
        }
      })
    }

    return () => {
      listener?.remove()
    }
  }

  return useSWRSubscription<Awaited<T>, any, SWRSubKey>(
    key,
    subscribe as any,
  )
}

Note the queryTimeout here!!! Since table changes may be frequent, we need to cancel the previous query, otherwise it will affect the efficiency of the query. Drizzle does not yet support canceling queries using AbortSignal, so we use setTimeout to handle it.

https://github.com/drizzle-team/drizzle-orm/issues/1602

OK, as long as we correctly set the key when requesting data, we can efficiently get the latest data. With pull-to-refresh and scheduled data synchronization, our app can achieve basic Local First.

What's Next?#

This week, I also used a webview to display the details of a feed entry, but it seems a bit slow when loading for the first time. I don't know if it requires special optimization strategies or if webview is just limited. Next week, I will explore rendering HTML to native components.

Let's take a look at what it looks like now!

Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.