Documentation Index

Fetch the complete documentation index at: https://docs.unqork.io/llms.txt

Use this file to discover all available pages before exploring further.

Best Practices - BYO

Prev Next

Follow the best practices below to build secure, high-performance, and maintainable BYO components.

Performance

Bundle Size Optimization

Minimize Bundle Size

Every BYO asset adds to the page load, so keep bundles small:

// ❌ Bad - imports entire library
import * as _ from 'lodash'

// ✅ Good - imports only what's needed
import debounce from 'lodash/debounce'
import throttle from 'lodash/throttle'

Lazy Load Heavy Dependencies

Load large libraries only when needed:

export class MyChart extends HTMLElement {
  private chartLib: any = null
  
  async loadChartLibrary() {
    if (!this.chartLib) {
      // Only load when actually rendering a chart
      this.chartLib = await import('d3')
    }
    return this.chartLib
  }
  
  async renderChart() {
    const d3 = await this.loadChartLibrary()
    // use d3...
  }
}

Use Code Splitting

Split components into separate bundles:

// src/entry.ts
export const myButton = {
  view: async () => (await import('./MyButton')).MyButton,
  model: async () => (await import('./MyButton.model')).MyButtonModel,
}

export const myChart = {
  view: async () => (await import('./MyChart')).MyChart,
  model: async () => (await import('./MyChart.model')).MyChartModel,
}

The page loads only the components it uses.

Monitor Bundle Size

Check the production bundle size:

npm run build
ls -lh dist/*.js

# Target: < 100KB per component (minified + gzipped)

Limit the Number of BYO Assets

Every BYO asset adds overhead to module loading. Follow the guidelines below:

  • Use BYO only when necessary.
  • Combine related components into one asset.
  • Avoid creating many single-purpose components.
  • Reuse components across applications.

Example:

❌ Bad - separate assets
- text-input.tar.gz
- number-input.tar.gz
- email-input.tar.gz

✅ Good - combined asset
- form-inputs.tar.gz (contains all three)

Limit How Often Operations Run

Rate-limit submission updates (debouncing):

import debounce from 'lodash/debounce'

export class MyInput extends HTMLElement {
  private updateSubmission = debounce((value: string) => {
    this.onSubmissionUpdate?.(value)
  }, 300)
  
  handleInput(e: Event) {
    const value = (e.target as HTMLInputElement).value
    this.updateSubmission(value)
  }
}

Limit how often events fire (throttling):

import throttle from 'lodash/throttle'

export class MySlider extends HTMLElement {
  private handleSlide = throttle((value: number) => {
    this.onValueChanged?.(value)
  }, 100)
}

Batch DOM Reads and Writes

Batch DOM reads before writes to avoid forcing the browser to recalculate layout on every change:

// ❌ Bad - interleaved reads and writes (causes reflow)
element.style.width = '100px'
const height = element.offsetHeight  // forces reflow
element.style.height = `${height}px`

// ✅ Good - batch reads, then writes
const height = element.offsetHeight
requestAnimationFrame(() => {
  element.style.width = '100px'
  element.style.height = `${height}px`
})

Security

Prevent Cross-Site Scripting (XSS) Attacks

Never use innerHTML with user input:

// ❌ Bad - vulnerable to XSS
set label(value: string) {
  this.querySelector('.label')!.innerHTML = value
}

// ✅ Good - escapes HTML
set label(value: string) {
  this.querySelector('.label')!.textContent = value
}

Sanitize HTML if you must render it:

import DOMPurify from 'dompurify'

set richText(value: string) {
  const clean = DOMPurify.sanitize(value)
  this.querySelector('.content')!.innerHTML = clean
}

Validate Input

Validate all prop values:

set count(value: unknown) {
  // Validate type
  if (typeof value !== 'number') {
    console.warn('Invalid count value:', value)
    return
  }
  
  // Validate range
  if (value < 0 || value > 1000) {
    console.warn('Count out of range:', value)
    return
  }
  
  this._count = value
  this.render()
}

Secure Third-Party Libraries

Audit dependencies:

# Check for known vulnerabilities
npm audit

# Fix vulnerabilities
npm audit fix

Keep libraries updated:

# Check for outdated packages
npm outdated

# Update dependencies
npm update

Review library code:

  • Use well-maintained libraries with active communities.
  • Check GitHub issues for security concerns.
  • Review license compatibility.
  • Avoid deprecated libraries.

Don't Expose Sensitive Data

Never log sensitive data:

// ❌ Bad
console.log('User data:', submissionData)

// ✅ Good
console.log('Submission updated')

Don't store secrets in code:

// ❌ Bad
const API_KEY = 'sk_live_1234567890'

// ✅ Good - use environment variables
const API_KEY = process.env.BYO_API_KEY

Maintainability

Validate Use Cases First

Before creating a BYO component, complete the following steps:

  1. Check if built-in components meet your needs.
  2. Explore logic components for business logic.
  3. Consider if CSS styling is sufficient.
  4. Validate that BYO is necessary.

Validating first avoids duplicating platform functionality and reduces maintenance overhead.

Document Your Components

Add JSDoc comments:

/**
 * Custom task list component.
 * 
 * Displays a list of tasks with checkboxes and delete buttons.
 * 
 * @example
 * ```typescript
 * element.items = [
 *   { id: '1', title: 'Task 1', completed: false }
 * ]
 * ```
 */
export class TaskList extends HTMLElement {
  /**
   * Array of task items to display.
   */
  set items(value: Task[]) {
    this._items = value
    this.render()
  }
}

Create a README:

# Task List Component

Custom component for displaying and managing tasks.

## Props

- `label` (string) - List heading
- `items` (Task[]) - Array of tasks
- `allowAdd` (boolean) - Show add button
- `allowDelete` (boolean) - Show delete buttons

## Events

- `onTaskToggled` - Fires when task is checked/unchecked
- `onTaskAdded` - Fires when add button is clicked
- `onTaskDeleted` - Fires when delete button is clicked

## Usage

See examples in [Module Builder Documentation](#).

Version Your Components

Use semantic versioning:

{
  "version": "1.2.3"
}
  • Major (1.x.x): Breaking changes.
  • Minor (x.2.x): New backward-compatible features.
  • Patch (x.x.3): Bug fixes.

Document breaking changes:

# CHANGELOG.md

## [2.0.0] - 2026-05-01

### Breaking Changes
- Renamed `onItemClick` to `onItemSelected`
- Changed `items` prop structure (now requires `id` field)

### Migration Guide
Update event handlers from `onItemClick` to `onItemSelected`.
Add `id` field to all items: `{ id: '1', ...rest }`.

Centralize Updates

One asset, many uses:

When you update a BYO asset, changes reflect everywhere it's used. Plan updates carefully:

  1. Test changes thoroughly in staging.
  2. Communicate updates to Creators.
  3. Provide migration guides for breaking changes.
  4. Consider backward compatibility.

Plan for Library Updates

Track third-party library versions:

{
  "dependencies": {
    "d3": "7.8.5",
    "lodash": "4.17.21"
  }
}

Set up dependency monitoring:

  • Enable Dependabot or Renovate.
  • Subscribe to library release notes.
  • Test updates in staging before production.

Accessibility

Semantic HTML

Use proper HTML elements:

// ❌ Bad - div button
this.innerHTML = `
  <div onclick="handleClick()">Click me</div>
`

// ✅ Good - semantic button
this.innerHTML = `
  <button>Click me</button>
`

ARIA Labels

Add labels for screen readers:

this.innerHTML = `
  <button aria-label="Delete task: ${task.title}">
    <span aria-hidden="true">×</span>
  </button>
`

Keyboard Navigation

Support keyboard interactions:

connectedCallback() {
  this.addEventListener('keydown', (e) => {
    if (e.key === 'Enter' || e.key === ' ') {
      e.preventDefault()
      this.handleClick()
    }
  })
}

Make elements focusable:

this.innerHTML = `
  <div class="card" tabindex="0" role="button">
    ...
  </div>
`

Color Contrast

Ensure sufficient contrast:

/* ❌ Bad - low contrast (2.5:1) */
.label {
  color: #999;
  background: #fff;
}

/* ✅ Good - high contrast (7:1) */
.label {
  color: #333;
  background: #fff;
}

Test with tools:

Focus Management

Visible focus indicators:

button:focus-visible {
  outline: 2px solid #0066cc;
  outline-offset: 2px;
}

Don't remove focus styles:

/* ❌ Bad */
*:focus {
  outline: none;
}

/* ✅ Good - custom but visible */
*:focus-visible {
  outline: 2px solid currentColor;
}

Error Handling

Graceful Degradation

Handle missing callbacks:

handleClick() {
  // Always use optional chaining
  this.onButtonClicked?.({ label: this._label })
}

Validate properties:

set items(value: unknown) {
  if (!Array.isArray(value)) {
    console.warn('items must be an array')
    this._items = []
    return
  }
  
  this._items = value
  this.render()
}

Error Boundaries

Wrap error-prone code:

async loadData() {
  try {
    const data = await import('./heavy-library.js')
    return data
  } catch (error) {
    console.error('Failed to load library:', error)
    this.showError('Failed to load component')
    return null
  }
}

User-Friendly Messages

Show helpful errors:

// ❌ Bad
throw new Error('Invalid input')

// ✅ Good
console.error('Invalid items prop: expected array, got', typeof value)
this.showError('Unable to display items. Please contact support.')

Testing

Test Across Browsers

Minimum browser support:

  • Chrome, Firefox, Safari, and Edge — two most recent versions of each.

Use cross-browser testing:

Test Edge Cases

Empty states:

it('handles empty items array', () => {
  element.items = []
  expect(element.querySelector('.task')).toBeNull()
})

Invalid input:

it('handles invalid items gracefully', () => {
  element.items = null as any
  expect(() => element.items = null).not.toThrow()
})

Large datasets:

it('handles 1000 items', () => {
  const items = Array.from({ length: 1000 }, (_, i) => ({
    id: String(i),
    title: `Task ${i}`,
    completed: false,
  }))
  
  element.items = items
  expect(element.querySelectorAll('.task')).toHaveLength(1000)
})

Automated Testing

Set up automated testing:

# .github/workflows/test.yml
name: Test
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm test
      - run: npm run lint

Deployment

Pre-Deployment Checklist

Before uploading to Unqork, verify the following:

  • All tests pass (npm test).
  • Linting passes (npm run lint).
  • Build succeeds (npm run build).
  • Bundle size is acceptable (< 100KB gzipped).
  • Tested in dev harness (npx byo preview).
  • Documented usage and properties.
  • Version updated in package.json.
  • CHANGELOG updated.

Staging First

Deploy to staging before production:

  1. Upload to the staging environment.
  2. Test in real modules.
  3. Verify integration with other components.
  4. Get stakeholder approval.
  5. Deploy to production.

Monitor After Deployment

Check for issues:

  • Browser console errors.
  • Performance regressions.
  • User feedback.
  • Error tracking tools like Sentry.

Common Pitfalls

Avoid DOM Manipulation Outside the Component

Don't modify the outer DOM:

// ❌ Bad - manipulates platform DOM
document.querySelector('.unqork-module').classList.add('custom')

// ✅ Good - only modifies own DOM
this.querySelector('.my-component').classList.add('active')

Don't Block the Main Thread

Avoid synchronous heavy operations:

// ❌ Bad - blocks UI
function processLargeDataset(items: Item[]) {
  return items.map(complexCalculation)
}

// ✅ Good - async with batching
async function processLargeDataset(items: Item[]) {
  const batchSize = 100
  const results = []
  
  for (let i = 0; i < items.length; i += batchSize) {
    const batch = items.slice(i, i + batchSize)
    results.push(...batch.map(complexCalculation))
    
    // Yield to browser
    await new Promise(resolve => setTimeout(resolve, 0))
  }
  
  return results
}

Clean Up Resources

Always remove event listeners in disconnectedCallback():

Without cleanup, event listeners keep references to the component instance, causing memory leaks as the browser cannot reclaim unused memory.

private handleIncrement = () => {
  this._count++
  this.render()
}

connectedCallback() {
  // Listen for custom command events
  this.addEventListener('increment', this.handleIncrement)
}

disconnectedCallback() {
  // Always clean up event listeners
  this.removeEventListener('increment', this.handleIncrement)
}

Framework-specific cleanup:

React

useEffect(() => {
  const host = containerRef.current?.getRootNode()?.host
  if (!host) return

  const handleIncrement = () => {
    setCount(c => c + 1)
  }

  host.addEventListener('increment', handleIncrement)
  
  // Cleanup function automatically called on unmount
  return () => {
    host.removeEventListener('increment', handleIncrement)
  }
}, [])

Vue

<script setup>
import { ref, onMounted, onUnmounted } from 'vue'

const count = ref(0)
let hostElement = null

const handleIncrement = () => {
  count.value++
}

onMounted(() => {
  const instance = getCurrentInstance()
  hostElement = instance?.proxy?.$el?.getRootNode()?.host
  if (!hostElement) return

  hostElement.addEventListener('increment', handleIncrement)
})

// Always pair onMounted with onUnmounted
onUnmounted(() => {
  if (!hostElement) return
  hostElement.removeEventListener('increment', handleIncrement)
})
</script>

Angular

export class MyComponent implements OnDestroy {
  private hostElement: HTMLElement | null = null
  
  ngAfterViewInit() {
    this.hostElement = this.elementRef.nativeElement.getRootNode()?.host
    if (!this.hostElement) return

    this.hostElement.addEventListener('increment', this.handleIncrement)
  }

  ngOnDestroy() {
    if (this.hostElement) {
      this.hostElement.removeEventListener('increment', this.handleIncrement)
    }
  }

  private handleIncrement = () => {
    this.count++
  }
}

Lit

export class MyComponent extends LitElement {
  private handleIncrement = () => {
    this._count++
  }

  override connectedCallback() {
    super.connectedCallback()
    this.addEventListener('increment', this.handleIncrement)
  }

  override disconnectedCallback() {
    super.disconnectedCallback()
    this.removeEventListener('increment', this.handleIncrement)
  }
}

Cancel async operations:

private abortController: AbortController | null = null

async fetchData() {
  this.abortController = new AbortController()
  
  try {
    const response = await fetch(url, {
      signal: this.abortController.signal
    })
    return response.json()
  } catch (error) {
    if (error.name === 'AbortError') {
      console.log('Fetch cancelled')
    }
  }
}

disconnectedCallback() {
  this.abortController?.abort()
}

Summary

Takeaways:

  • Keep bundles small by splitting your bundle and loading code on demand.
  • Validate use cases before creating BYO components.
  • Prevent cross-site scripting (XSS) attacks and validate all inputs.
  • Make components accessible through ARIA labels, keyboard navigation, and color contrast.
  • Test thoroughly across browsers and edge cases.
  • Document usage and maintain version history.
  • Monitor performance and errors after deployment.

Next Steps:


Changelog

Date Change
2026-05-01 Initial publication