Latest posts

Using new React Activity to hide offscreen content

React 19.2 shipped with the [https://react.dev/reference/react/Activity](new Activity component).

This is intended to hide tab contents etc. when its not on-screen, caching the DOM state so restores as faster than if you hid it entirely (ie. rendering null) and preserving scroll-state. But being faster than rendering the content.

However, in my use cases, it would be great to be able to do the same operation for offscreen content. The problem is Activity adds display: none to its children, and its not certain that they are rendered at all (they could) be lazily rendered.

To fix this, I created the OffscreenActivity component. It uses IntersectionObserver and ResizeObserver to detect when the content is on-screen, and then set the Activity mode to visible. If the component is not visible, it measures the bounding box of the children, and creates a placeholder div.

You can handle the case of the content changing height, by passing a deps array, this can for example be a lastModifiedAt.toISOString(), so that when something modifies the content it is properly remeasured.

The code

import { Activity, useEffect, useRef, useState } from "react";

interface OffscreenActivityProps {
  children: React.ReactNode;
  /** When any value changes, content is briefly shown to re-measure dimensions */
  deps?: readonly unknown[];
  /** IntersectionObserver rootMargin — how far outside viewport to start rendering. Default: '200px' */
  rootMargin?: string;
}

const emptyDeps: readonly unknown[] = [];

export function OffscreenActivity({
  children,
  deps = emptyDeps,
  rootMargin = "200px",
}: OffscreenActivityProps) {
  const containerRef = useRef<HTMLDivElement>(null);
  const [isIntersecting, setIsIntersecting] = useState(false);
  const [hasMeasured, setHasMeasured] = useState(false);
  const [size, setSize] = useState<{ width: number; height: number } | null>(
    null,
  );

  // IntersectionObserver — detect when container enters/leaves viewport
  useEffect(() => {
    const el = containerRef.current;
    if (!el) return;
    const observer = new IntersectionObserver(
      ([entry]) => setIsIntersecting(entry.isIntersecting),
      { rootMargin },
    );
    observer.observe(el);
    return () => observer.disconnect();
  }, [rootMargin]);

  // Reset measurement flag when deps change (also fires on mount)
  // eslint-disable-next-line react-hooks/exhaustive-deps
  useEffect(() => {
    setHasMeasured(false);
  }, deps);

  // Content is shown when intersecting OR when we haven't measured yet
  const shouldShow = isIntersecting || !hasMeasured;

  // ResizeObserver — measure content dimensions while showing
  useEffect(() => {
    if (!shouldShow) return;
    const el = containerRef.current;
    if (!el) return;
    const observer = new ResizeObserver(([entry]) => {
      setSize({
        width: entry.contentRect.width,
        height: entry.contentRect.height,
      });
      setHasMeasured(true);
    });
    observer.observe(el);
    return () => observer.disconnect();
  }, [shouldShow]);

  // When hidden, apply last measured size as placeholder
  const style: React.CSSProperties | undefined =
    !shouldShow && size
      ? { width: size.width, height: size.height }
      : undefined;

  return (
    <div ref={containerRef} style={style}>
      <Activity mode={shouldShow ? "visible" : "hidden"}>{children}</Activity>
    </div>
  );
}

Usage

Wrap any expensive section of your page, to not render it when off-screen (pass rootMargin to tweak how far offscreen it should be, to hide):

<OffscreenActivity>
  <HeavyDashboardWidget />
</OffscreenActivity>

If the content's size depends on some data, pass it as deps so the component knows to re-measure when its content updates:

<OffscreenActivity deps={[items.length, selectedFilter]}>
  <BigFilterableTable items={items} filter={selectedFilter} />
</OffscreenActivity>

When to use it

This works great for very horizontally tall pages, with lots of complex components. You can hide entire sections using this, speeding rendering up considerably on slower devices. Just make sure to update deps properly, otherwise content may "pop" while scrolling, which is not a good look!

Starting on my own

After years at Unfold, I'm going solo. I'm freelancing and building my own product, empowered by AI to do alone what used to require a team.

Exciting times! Reach out if you need help with a project.

Back in action

It's been a while since my last post. Actually, it's been several years. But I'm back in action!

A few significant things have changed in the meantime. Most importantly, I've changed my name from Hampus Nilsson to Hampus Borgos (after marrying my wife, who runs borgosdesigns.no).

Along with the name change, I've moved this blog to a new domain. My old domain, hjnilsson.com, is unfortunately no longer mine and is now parked by someone else. You can now find my blog and portfolio at kodeverk.com.

I'm looking forward to getting back into the habit of sharing my thoughts and projects here. Stay tuned!

A simple math parser

Here is a simple math expression parser for TypeScript.

It can take an input string like "2+2\*4" and properly return 10 as the result. This can be used as a quick and lightweight way to do math on user-input expressions.

export type ParseResult = number | '' | Error

type Token =
    | { type: 'number'; value: number }
    | { type: 'op'; value: '+' | '-' | '*' | '/' }
    | { type: 'lparen' }
    | { type: 'rparen' }

function isDigit(char: string): boolean {
    return char >= '0' && char <= '9'
}

function normalizeDecimals(input: string): string {
    // Replace commas with dots and remove spaces
    return input.replace(/,/g, '.').replace(/[\s]/g, '')
}

function tokenize(input: string): Token[] | Error {
    const src = normalizeDecimals(input)
    const tokens: Token[] = []
    let i = 0

    while (i < src.length) {
        const ch = src[i]

        if (isDigit(ch) || ch === '.') {
            const start = i
            let seenDot = ch === '.'
            i++
            while (i < src.length) {
                const c = src[i]
                if (isDigit(c)) {
                    i++
                } else if (c === '.') {
                    if (seenDot) break
                    seenDot = true
                    i++
                } else {
                    break
                }
            }
            const numStr = src.slice(start, i)
            if (numStr === '.' || numStr === '')
                return new Error('Invalid number')
            const value = Number(numStr)
            if (Number.isNaN(value)) return new Error('Invalid number')
            tokens.push({ type: 'number', value })
            continue
        }

        if (ch === '+' || ch === '-' || ch === '*' || ch === '/') {
            tokens.push({ type: 'op', value: ch })
            i++
            continue
        }

        if (ch === '(') {
            tokens.push({ type: 'lparen' })
            i++
            continue
        }
        if (ch === ')') {
            tokens.push({ type: 'rparen' })
            i++
            continue
        }

        return new Error('Invalid character')
    }

    return tokens
}

// Parser using recursive descent with support for unary +/-
// Grammar (after tokenization and whitespace removal):
// Expression := Term ((+|-) Term)*
// Term       := Factor ((*|/) Factor)*
// Factor     := (+|-) Factor | Primary
// Primary    := Number | ( Expression )

function parseAndEvaluate(tokens: Token[]): number | Error {
    let pos = 0

    function peek(): Token | undefined {
        return tokens[pos]
    }

    function consume<T extends Token['type']>(
        type: T,
    ): Extract<Token, { type: T }> | undefined {
        if (tokens[pos]?.type === type) {
            // eslint-disable-next-line @typescript-eslint/no-explicit-any
            return tokens[pos++] as any
        }
        return undefined
    }

    function parseExpression(): number | Error {
        let left = parseTerm()
        if (left instanceof Error) return left

        // eslint-disable-next-line no-constant-condition
        while (true) {
            const t = peek()
            if (t && t.type === 'op' && (t.value === '+' || t.value === '-')) {
                pos++
                const right = parseTerm()
                if (right instanceof Error) return right
                left = t.value === '+' ? left + right : left - right
            } else {
                break
            }
        }
        return left
    }

    function parseTerm(): number | Error {
        let left = parseFactor()
        if (left instanceof Error) return left

        // eslint-disable-next-line no-constant-condition
        while (true) {
            const t = peek()
            if (t && t.type === 'op' && (t.value === '*' || t.value === '/')) {
                pos++
                const right = parseFactor()
                if (right instanceof Error) return right
                if (t.value === '/') {
                    if (right === 0) {
                        // Per requirement: return 0 on division by zero
                        left = 0
                    } else {
                        left = left / right
                    }
                } else {
                    left = left * right
                }
            } else {
                break
            }
        }
        return left
    }

    function parseFactor(): number | Error {
        const t = peek()
        if (!t) return new Error('Unexpected end')

        // Unary operators
        if (t.type === 'op' && (t.value === '+' || t.value === '-')) {
            pos++
            const val = parseFactor()
            if (val instanceof Error) return val
            return t.value === '+' ? +val : -val
        }

        return parsePrimary()
    }

    function parsePrimary(): number | Error {
        const t = peek()
        if (!t) return new Error('Unexpected end')
        if (t.type === 'number') {
            pos++
            return t.value
        }
        if (t.type === 'lparen') {
            pos++
            const value = parseExpression()
            if (value instanceof Error) return value
            const r = consume('rparen')
            if (!r) return new Error('Unbalanced parentheses')
            return value
        }
        return new Error('Unexpected token')
    }

    const result = parseExpression()
    if (result instanceof Error) return result
    if (pos !== tokens.length) return new Error('Trailing input')
    return result
}

export function parseMath(input: string): ParseResult {
    const trimmed = input.trim()
    if (trimmed === '') return ''

    const tokens = tokenize(trimmed)
    if (tokens instanceof Error) return tokens

    // Disallow expressions that end with an operator
    if (tokens.length > 0) {
        const last = tokens[tokens.length - 1]
        if (last.type === 'op') return new Error('Ends with operator')
        // Leading illegal operator: '*' or '/'
        const first = tokens[0]
        if (
            first.type === 'op' &&
            (first.value === '*' || first.value === '/')
        ) {
            return new Error('Illegal leading operator')
        }
    }

    const value = parseAndEvaluate(tokens)
    return value
}

I've used this as a common enhancement in projects I've been working on, for number input fields. The luxury of not leaving the application to do a calculation (especially on mobile) is both a great and a subtle improvement.

Mocking the Apollo Query provider

When working with React components that use Apollo Client, you often need to mock the Apollo Provider for testing or Storybook. This allows you to test your components in isolation without setting up a full GraphQL server or making actual network requests.

The challenge is that Apollo's ApolloProvider expects a full Apollo Client instance, which can be complex to mock. However, for most testing scenarios, you don't need the full client—you just need to mock the query methods that your components use.

Here's a simple solution that creates a mock Apollo Provider that accepts predefined query results. This is perfect for:

  • Storybook: Show your components with different data states without a backend
  • Unit tests: Test component rendering and behavior with controlled data
  • Integration tests: Verify component interactions without network calls

The MockApolloQuery component wraps your components and provides mock query results based on the query name. It handles both readQuery and watchQuery methods, which covers most common Apollo usage patterns.

import { ApolloProvider } from 'react-apollo'
import React from 'react'

function resultForQuery(
  ctx: any,
  results: { [queryName: string]: { data?: any; loading?: boolean } },
) {
  const query = ctx.query.definitions.find(
    (op: any) => op.kind === 'OperationDefinition' && Object.keys(results).includes(op.name.value),
  )

  if (query) {
    return {
      subscribe: () => ({
        query: 'dummy-MockApolloQuery',
      }),
      resetQueryStoreErrors: () => {},
      options: {
        fetchPolicy: 'cache-only',
      },
      setOptions: async () => new Promise(resolve => setTimeout(resolve, 0)),
      getCurrentResult: () => {
        return {
          data: results[query.name.value].data,
          loading: results[query.name.value].loading ?? false,
        }
      },
    }
  }

  throw new Error(
    `Called with unknown query ${ctx.query.definitions
      .filter((op: any) => op.kind === 'OperationDefinition')
      .map((op: any) => op.name.value)}, must mock all queries for MockApolloQuery to work.`,
  )
}

export function MockApolloQuery({
  results,
  children,
}: {
  results: { [queryName: string]: { data?: any; loading?: boolean } }
  children: React.ReactNode
}) {
  return (
    <ApolloProvider
      client={
        {
          readQuery: (ctx: any) => resultForQuery(ctx, results),
          watchQuery: (ctx: any) => resultForQuery(ctx, results),
        } as any
      }
    >
      {children}
    </ApolloProvider>
  )
}

It is the best.

View ArchiveOlder posts →