Pagefold

Using hooks

Run your code at any point of a visit, wait for it, run it first, or replace what Pagefold does.

A hook is a named moment in a visit. You attach a handler and Pagefold calls it with the visit and the hook’s arguments:

pagefold.hooks.on('page:view', (visit, { url, title }) => {
  console.log(`Now on ${title} (${url})`)
})

The handler returns a function to remove it:

const off = pagefold.hooks.on('page:view', handler)
off()

Waiting

Handlers can be async. Pagefold waits for each one before moving to the next step, so a handler can hold the visit:

pagefold.hooks.on('content:replace', async () => {
  await document.fonts.ready
})

Options

OptionWhat it does
before: trueRun before Pagefold’s own work for that hook
replace: trueReplace Pagefold’s own work
once: trueRun once, then remove the handler
priority: nLower numbers run first. Default: 0

There are shortcuts:

pagefold.hooks.before('content:replace', handler)
pagefold.hooks.once('visit:end', handler)
pagefold.hooks.replace('scroll:top', handler)

Or declare them when you create Pagefold, adding the modifier after the hook name:

new Pagefold({
  hooks: {
    'page:view': (visit, args) => {},
    'content:replace.before': (visit, args) => {},
    'visit:end.once': (visit, args) => {},
  },
})

Order inside a hook

  1. handlers with before, by priority;
  2. Pagefold’s own work (the “default handler”), or the replace handler instead;
  3. all other handlers, by priority.

Replacing

A replace handler gets the default handler as a third argument, so you can add to it or skip it:

pagefold.hooks.replace('scroll:top', (visit, { options }, defaultHandler) => {
  if (visit.to.url.startsWith('/app')) return false // do not scroll
  return defaultHandler(visit, { options })
})

Some hooks expect a return value from the default (page:load returns the page, scroll:top and scroll:anchor return whether they scrolled). Keep that in mind when replacing.

Several replace handlers on one hook are chained: each receives the next one as its default handler.

Sharing data with other handlers

The arguments object is the same for every handler of a hook, so changing it is a way to talk to the others. animation:out:await has { skip: boolean }: set it to true and the default wait is skipped.

Custom hooks

Your own code, or a plugin, can have hooks too. They work like the built-in ones, and anyone can listen to them the same way. A hook is useful when something your code does should be extendable: other code can run before it, after it, or in place of it.

A small example: a Cart plugin whose add() is a hook.

import { Plugin } from 'pagefold'

export default class Cart extends Plugin {
  name = 'Cart'
  items = []

  mount() {
    // 1. Register the hook name. Do it once, before anyone listens.
    this.pagefold.hooks.create('cart:add')
  }

  add(id, quantity = 1) {
    const args = { id, quantity }

    // 2. Fire it. The last function is what happens by default.
    return this.pagefold.hooks.call('cart:add', undefined, args, (visit, args) => {
      this.items.push({ id: args.id, quantity: args.quantity })
      return this.items.length // the value `call` gives back
    })
  }
}

Then anyone can listen, with the same methods as for any other hook:

const cart = new Cart()
const pagefold = new Pagefold({ plugins: [cart] })

// Before the default: change the arguments, or stop it by throwing
pagefold.hooks.before('cart:add', (visit, args) => {
  if (args.quantity > 10) args.quantity = 10
})

// After the default: react to it
pagefold.hooks.on('cart:add', (visit, args) => {
  analytics.track('add_to_cart', args)
})

// Instead of the default: take over, and call the original if you want it
pagefold.hooks.replace('cart:add', (visit, args, original) => {
  return original(visit, args)
})

const count = await cart.add('sku-1', 50)
// the quantity was reduced to 10, the item was added, the event was sent,
// and `count` is 1: what the default handler returned

The parts of call:

ArgumentWhat it is
'cart:add'The name of the hook. Use area:action so it never clashes with another
undefinedThe visit. Leave it out and the current visit is used. Pass one if the hook belongs to a particular visit
argsAn object shared by every handler. Changing it is how they talk to each other
the last functionWhat happens by default. Optional: without it the hook is only a notification

call waits for every handler, so they may be async, and it returns whatever the default handler (or the replace handler) returned. Use callSync when you cannot wait.

Two things to remember:

  • Create the hook before anyone listens. Listening to a name that does not exist logs a warning and does nothing. A plugin creates it in mount(), which runs when Pagefold starts, before your own handlers are added. Creating a name twice is harmless.
  • Always pick a name of your own. Do not reuse a built-in name such as page:view.

In TypeScript

Declare the hook once and every on, before, replace and call knows its arguments:

declare module 'pagefold' {
  interface HookDefinitions {
    'cart:add': { id: string; quantity: number }
  }
}

Put it in any .ts file of your project, or in a .d.ts that is part of it. After that:

pagefold.hooks.before('cart:add', (visit, args) => {
  args.quantity // number
})

pagefold.hooks.call('cart:add', undefined, { id: 'sku-1', quantity: '2' })
// error: quantity must be a number

pagefold.hooks.on('cart:remove', () => {})
// error: there is no hook with that name

The value that call returns is typed unknown for hooks you add, so say what it is: const count = (await cart.add('sku-1')) as number.

Without the declaration the hook still works, but TypeScript will not know its name. In that case, cast it: 'cart:add' as HookName, importing HookName from pagefold.

Sync and async

hooks.call waits for every handler. hooks.callSync does not wait: it is used where the browser needs an immediate answer, such as clicks. Handlers of link:*, scroll:*, cache:* and history:popstate should therefore not rely on being awaited.