Theme
Version
GitHub PyPI Discord
On this page

Browser APIs

Look up the JavaScript names Citry adds in the browser: what each one is for, what it takes and returns, and when it fails. Vue still provides component state, props, events, and lifecycle hooks. This page covers only what Citry adds on top.

The names fall into five groups:

Component JavaScript

$component

Register the browser code for one component class. Call it in Component.js, or in the file named by Component.js_file. Each component class registers one object of Vue options, or one callback:

$component({
  props: {
    label: String,
  },
  data() {
    return { open: false };
  },
  methods: {
    toggle() {
      this.open = !this.open;
    },
  },
});

The object accepts props, inject, data(), a synchronous setup(), methods, computed, and lifecycle hooks, with Vue's usual rules. Citry combines them with its own component code.

The callback form is short for onServerRender:

$component(({ component, revision, onEvent }) => {
  console.log(component, revision, onEvent);
});

Citry rejects:

  • mixins and extends;
  • an async setup, or a setup that returns a render function. setup must return a plain object of bindings, or undefined;
  • a public name that is also a js_data() key or an Events helper.

Citry ignores a render function or a template option, because it generates the render function from the component's template.

onServerRender

Run code when the component mounts and again each time the server renders it, for example to connect a non-Vue widget to the new HTML. Return a function to clean up:

$component({
  onServerRender({ component, revision, onEvent }) {
    const root = component.$refs.root;
    if (!(root instanceof HTMLElement)) return;

    const controller = connectWidget(root);
    const stop = onEvent("Cart:changed", (detail) => {
      refreshBadge(detail);
    });
    console.log("rendered revision", revision);
    return () => {
      stop();
      controller.disconnect();
    };
  },
});

The template declares the element the code uses:

<section ref="root"></section>

The callback receives one object with these members:

NameValue
componentThe live Vue instance of this component.
revisionA number that identifies the server render the component now shows. It changes when a server render updates the page.
onEvent(name, handler)Listens for an event this component's server handler dispatches. Returns a function that stops listening.
idThe render ID: the ID Citry gives this rendered component on the page. It is the value Citry.events.send() accepts and the instance in citry:events:* details. null when the component has none. Read-only.
elsThe component's top-level elements on the page, as an array.
stateThe same object as component.$state, or null when the component declares no Events.
sendEvent(name, args?, opts?)Calls component.$sendEvent.
loading(name?)Calls component.$loading.
error(name?)Calls component.$error.
i18nThe same service as component.$i18n, or null outside a client i18n provider or when the app does not configure the i18n extension.

When it runs. On mount, and after each server render the page applies to this component. A Vue update made only in the browser does not call it.

Order. Children's callbacks run before their parent's, and siblings run in the order they first mounted. This holds on the first mount and after a server render. By the time the first callback runs, every component mounted by that mount or render has run its mounted hook. If a callback throws, no later callback of that mount or render runs, and the page's Vue app stops. A rejected async callback only logs.

Each callback sees the page as the earlier callbacks left it, after Vue applied any change they made. If a callback changes state so that Vue removes a component whose callback has not run yet, that component gets no callback. A component that a callback adds gets its own first callback after the others have run.

Cleanup. The callback returns a cleanup function or undefined; any other value is an error. Citry calls the cleanup before the next run and when the component unmounts. Vue effects created synchronously inside the callback stop at the same time. Code that runs later, such as a timer or a Promise callback, must clean up after itself.

Async callbacks. The callback may be async. Citry does not wait for it: the page becomes ready, and later server renders apply, while it runs. It may resolve to a cleanup function. If the next run or the unmount happens first, Citry calls that cleanup as soon as the Promise resolves. A rejected Promise, or one that resolves to anything else, is logged to the console as a [Citry] error and does not stop the page:

$component({
  async onServerRender({ component }) {
    const { createChart } = await import("/static/chart.js");
    const chart = createChart(component.$refs.chart);
    return () => chart.destroy();
  },
});

Without Events. On a component that declares no Events class, loading() returns false, error() returns null, and sendEvent() returns a rejected Promise that says the component declares no Events class.

els updates. els stays one array for the component's whole life. Citry refills it after each server render, so an els you saved earlier stays current. A change made only in the browser, such as a v-if toggle, shows up in els after the next server render.

init. init is another name for onServerRender in the object form and receives the same object. A definition that names both throws an error when its script loads:

$component({
  props: { label: String },
  init({ component, els }) {
    els[0]?.setAttribute("title", component.label);
  },
});

onEvent in the callback

Listen for an event that this component's server handler dispatches, for as long as the current callback run lasts. It takes the same arguments as $onEvent. Citry removes the listener before the next run and when the component unmounts, so adding it on every run never adds it twice:

$component(({ els, onEvent }) => {
  onEvent("Cart:changed", (detail) => {
    els[0]?.setAttribute("data-items", String(detail.count));
  });
});

On a component without Events, calling onEvent is not an error, but the listener never runs, because no server handler can dispatch to it.

js_data() members

Read server data in the browser. Every top-level key returned by Component.js_data() becomes a reactive member of the Vue instance. Templates read it directly, and JavaScript reads or assigns it through this or the callback's component.

Citry updates these keys each time the page applies a server render to the component.

A key cannot have the same name as local Vue data, a setup binding, a prop, an injection, a method, a computed value, or one of the names Citry reserves on the instance. Python raises an error when the component renders with a key that starts with $ or _, or with the key citryId.

Events helpers

A component that declares an Events class gets these helpers in its template and on its Vue instance (this in Vue options). See Server events for how to declare handlers.

$state

Read and change the component's State fields in the browser. This is Citry's own reactive object, filled from the component's State class. It is separate from Vue's data() and from any Pinia store.

<button @click="$state.count += 1">Add one</button>
<output v-text="$state.count"></output>

Assigning a whole field that the browser may change updates the page at once, and sends the new value with the component's next event call that is not a GET and not to a handler declared with @event(detached=True). Validate it on the server like any user input.

  • Nested values, and fields not listed as browser-writable, are read-only.
  • Fields that are not public do not appear on $state.
  • Assigning an unknown field, a field that is not browser-writable, or a nested value throws an Error at once. A value that is not plain JSON data throws a TypeError. Either way the old value stays.

When a handler changes State, its response updates these fields even if the component does not render again. A field the browser changed but has not sent yet keeps the browser's value.

$loading

Check whether a server call is waiting or running, for example to disable a button. Returns true or false. Pass a handler name to check only that handler. An unknown handler name throws an error.

<button :disabled="$loading('save')">
  <span v-if="$loading('save')">Saving...</span>
  <span v-else>Save</span>
</button>

$error

Read the newest handler error, or null, to show it in the template. Pass a handler name to read only that handler's error. An unknown handler name throws an error. A successful call clears its own handler's error.

<p v-if="$error('save')" v-text="$error('save').message"></p>

$sendEvent

Call a server handler from JavaScript when your code needs the result. When it does not, use an @c-* binding in the template instead.

$sendEvent(name, args?, opts?)

args must be a plain object; anything else rejects the Promise. For example:

const result = await this.$sendEvent("preview", {page: 2});

It returns a Promise for the data the handler returns. The Promise rejects with an event error object when the call fails. An unknown handler name, or a component that declares no Events, also rejects the Promise instead of throwing.

The optional third argument takes these options:

OptionMeaning
timeoutMilliseconds to wait for the server before the call fails. Overrides the page default.
waittrue, the default, waits for this app's earlier calls. false sends the call at once. Only a handler declared with @event(detached=True) accepts it.

Calls from one Vue app go to the server one at a time, in order, because each server render builds on the one before it. A handler declared with @event(detached=True) must not render or change State, so a call to it can skip ahead with wait: false (details). For any other handler, wait: false rejects the Promise with a TypeError. To let a new call replace an older call to the same handler, as live search needs, declare the handler with @event(latest_wins=True). An unknown option also rejects, and the error names it.

$onEvent

Run browser code when this component's server handler dispatches an event:

const stop = this.$onEvent("Cart:changed", (detail) => {
  refreshBadge(detail);
});

The callback receives the event's detail, and stop() removes the listener. A listener added from mounted(), a method, or a timer lasts until the component unmounts or mounts again; a server render does not remove it. Call $onEvent once per instance, for example in mounted(), so the same listener is not added twice.

An empty event name, or a callback that is not a function, throws a TypeError.

A call made while this component's own onServerRender callback is running (not later, from a timer or Promise it started) belongs to that run instead, like the callback's own onEvent: Citry removes the listener before the next run.

After the component unmounts, $onEvent adds nothing. On a component without Events, no server handler can dispatch to it, so $onEvent adds nothing and returns a stop() that does nothing.

Page-wide settings

Citry.configure

Tell Citry where the page's CSRF token comes from. Citry's browser code sends the token with every request it makes to Citry's routes except GET requests: event calls, and the request that loads messages when $i18n switches locale. A web framework that checks CSRF tokens, such as Django's CsrfViewMiddleware, refuses these requests without it. See Protect against CSRF.

Call it in a script that runs after Citry's runtime has loaded, such as a component's js, and before the first event call or language switch.

Citry.configure({
  csrf: {
    token: () => document.querySelector(
      '[name="csrfmiddlewaretoken"]',
    ).value,
  },
});
csrf optionMeaning
tokenThe token: a string, or a function that returns one. Citry calls the function before each request.
cookieThe name of the cookie to read the token from when token is not set. The default is csrftoken.
headerThe request header that carries the token. The default is X-CSRFToken.

With no Citry.configure call, Citry reads the csrftoken cookie and sends it as X-CSRFToken, which is what Django expects. Each csrf value replaces the earlier one as a whole, and requests sent afterwards use it. When the token is empty, or the cookie is missing, Citry sends no token header.

Citry's own requests (event calls sent by the built-in fetch transport, and the message request) send the token only to an address on the page's own origin: the same scheme, host, and port as the page. A request to any other address goes without it, even when you set token yourself. A custom transport gets the token whatever address it sends to. A page in an iframe sandboxed without allow-same-origin has no origin of its own, so it sends no token.

If your token function throws, the request that needed it fails: the event call rejects, or the language switch fails and the old language stays.

The call throws a TypeError when the argument or csrf is not a plain object, when either has an option not listed here, when cookie is empty, or when token is neither a string nor a function. It also throws when header is not a valid header name, or is a name the token cannot use: Content-Type and names that start with X-Citry-, which Citry uses for its own headers, and names the browser does not let a page send, such as Cookie, Origin, and names that start with Sec- or Proxy-.

Citry.events.configure can set a different token for event calls. Event calls then use that token, and the request that loads messages keeps using the Citry.configure token.

Page-wide events API

Citry.events

Call handlers, listen for events, and change how calls are sent, from ordinary scripts or other browser code outside a component:

await Citry.events.send("render_abc123", "refresh", {page: 2});

The object has five methods.

Citry.events.send

Call a server handler on any mounted component:

Citry.events.send(target, name, args?, opts?)

target is a current render ID (the id in the onServerRender callback), or an element inside the mounted component. The Promise and opts work as for $sendEvent. The Promise rejects when no mounted component matches target, when more than one does, when the handler name is unknown, or when args is not a plain object.

Citry.events.on

Listen for an event that any component's server handler dispatches:

const stop = Citry.events.on("Cart:changed", (detail) => {
  updateHeader(detail);
});

The callback receives the event's detail, and the returned function removes the listener. Unlike $onEvent, this listener hears the event from every component on the page. An empty event name, or a callback that is not a function, throws a TypeError.

Citry.events.configure

Set page-wide defaults for event calls:

Citry.events.configure({
  timeout: 45_000,
  transport: "bridge",
});
OptionMeaning
csrfWhere the CSRF token for event calls comes from: {token} with a string or a function that returns one, or {cookie} with a cookie name. header names the request header, X-CSRFToken by default. It replaces the page-wide Citry.configure setting for event calls only.
timeoutMilliseconds before a call rejects. The default is 30000. A call's own timeout option wins.
transportThe name of a transport added with registerTransport. The default is "fetch".
urlThe base URL of the event routes. Citry normally reads it from the page. Citry resolves a relative URL against the page's own address and ignores a <base> element.

Each call merges its options into the earlier ones. Citry reads them each time it sends a call, so calls made afterwards use the new values. A new csrf value replaces the earlier one as a whole.

Without csrf here, event calls use the Citry.configure setting. When that setting reads a cookie, it does so for every transport, including a custom one. Once you pass csrf here, Citry reads csrf.cookie only for the built-in fetch transport, and a custom transport gets a token only from csrf.token.

The built-in fetch transport sends the CSRF token only when the call's URL is on the page's own origin, as described under Citry.configure. It does not follow an HTTP redirect: when the server answers an event call with one, the call fails and its Promise rejects. To send the visitor to another page, return actions.Redirect from the handler.

The argument and its csrf value must be plain objects, or the call throws a TypeError. It also throws a TypeError for a csrf.header that Citry.configure would reject. Citry ignores an unknown option. When transport names a transport that was never registered, every later event call fails with "Citry Events transport is not registered".

Citry.events.registerTransport

Send event calls through your own code instead of the browser's fetch, such as a native app bridge. Register the transport under a name, then select it with configure:

Citry.events.registerTransport("bridge", {
  send: (envelope, request) => sendThroughHost(envelope, request),
});

send receives the envelope, the JSON object Citry would post to the event route, and a description of the HTTP request. It returns the JSON object the server would answer with (the result envelope), or a Promise for it. Registering a name again replaces the earlier transport. An empty name, or an object without a send function, throws a TypeError. Custom event transports shows a complete example.

Citry.events.applyActions

Apply the actions array from a result envelope to the page. Custom transports, integration tests, and code that intercepts Citry's responses use it:

await Citry.events.applyActions(result.actions);

Citry checks the array, applies the actions in order, and returns a Promise. The Promise rejects when:

  • the array is not a valid action list;
  • an action targets a component that is no longer on the page;
  • the actions target components in different Vue apps;
  • a state action has no publicState, the component's public State values (this rejects with a TypeError).

Custom event transports

A custom transport replaces fetch for event calls. Citry calls send(envelope, request) for each call and expects the result envelope, or a Promise for it:

Citry.events.registerTransport("bridge", {
  send(envelope, request) {
    const get = request.method === "GET";
    return bridge.request(request.url, {
      method: request.method,
      headers: request.headers,
      body: get ? undefined : JSON.stringify(envelope),
      signal: request.signal,
    });
  },
});
Citry.events.configure({transport: "bridge"});

request describes the HTTP request the built-in transport would send. A GET handler carries its arguments in url and has no body, so send the envelope only for other methods:

FieldValue
urlThe event route, with the query string for a GET handler.
methodThe HTTP method, usually POST.
headersA new object with the request headers, including the CSRF token on calls that are not GET.
signalAn AbortSignal that fires when the call times out or Citry cancels it, for example because the component was removed.

Send request.headers to the server with the envelope. When a handler returns a render, the server reads the X-Citry-Vue-App, X-Citry-Vue-Occurrence, and X-Citry-Vue-Revision headers to find the component the browser shows. Without them the call fails with "Vue Events requires current app, occurrence, and revision headers." A server bridge that calls EventsDispatcher.dispatch itself passes these headers in TransportContext.headers.

citry:events:* events

Citry fires DOM events around each event call, so other code can show a spinner, log failures, or react when a result is dropped:

EventWhenExtra detail fields
citry:events:beforeBefore the call is sent. Calling preventDefault() stops the call.
citry:events:afterAfter the call finishes.ok
citry:events:errorWhen the call fails.error
citry:events:swappedAfter a render replaced elements.els
citry:events:staleWhen the result will not reach the page.reason

Every detail includes instance, class, and event. Each event bubbles from the first element of the component that made the call. When that component has no element on the page, for example because it was removed before the call finished, the event fires on document instead, so a listener on document still hears it. instance and class then name the component as Citry last saw it.

stale says in reason why the result was dropped:

reasonWhat happened
supersededA newer call to the same handler replaced this one, so the actions of this call that have not started yet are dropped, delayed ones included. This happens only for a handler declared with @event(latest_wins=True).
retiredThe component was removed or replaced before the call finished.
epochThe page accepted a newer result for the component first, so the rest of this one was dropped. This also happens when the call's own Render replaced the component and the page then accepted a newer result for the new component. A delayed redirect is never dropped for this reason. A result from a handler declared with @event(detached=True) never counts as newer, and its own actions without a delay are never dropped for this reason.
disposedThe page's Vue app was shut down.
versionThe server no longer accepts the State this page holds, usually because the app was deployed or its signing secret changed after the page loaded.

For version, the call also rejects with a stale_state error, and Citry asks the user once per page whether to reload. To show your own message instead, cancel the event:

document.addEventListener("citry:events:stale", (event) => {
  if (event.detail.reason !== "version") return;
  event.preventDefault();
  showUpdateBanner();
});

The Events guides cover State, template bindings, returned actions, and event routes.

Vue and translations

Citry.vue

Use Vue's Composition API functions in a component script without an import. Citry.vue is the exact Vue runtime the page uses:

$component({
  setup() {
    const query = Citry.vue.ref("");
    const normalized = Citry.vue.computed(() => query.value.trim());
    return { query, normalized };
  },
});

Citry.vue.use

Install a Vue plugin, such as a store or a global directive, on every Vue app Citry creates on the page. Citry calls app.use(plugin, ...options) on each app before it mounts:

Citry.vue.use({
  install(app) {
    app.directive("autofocus", {
      mounted(el) {
        el.focus();
      },
    });
  },
});

A component template can then use v-autofocus.

Call it after Citry's runtime has loaded (Citry.vue does not exist before that) and before Citry creates its first Vue app. Three places work:

  • A defer script in the page <head>. A page Citry renders loads the runtime with an ordinary script and starts its apps from a module script, so a deferred script runs between the two:

    <script defer src="/static/vue-plugins.js"></script>
    
  • A script an extension adds to ctx.early_scripts in on_dependencies(). These run after the Vue runtime loads and before any component's JavaScript, so on an interactive page they run before the Vue app is created.

  • A component's own JavaScript, when the component is in the page's first app. It runs before the Vue app is created, and the plugin then applies to the whole app, not only to that component.

Errors and repeated calls:

  • A call after Citry has created a Vue app throws an Error, because that app would run without the plugin. Move the call earlier.
  • A value that is neither a function nor an object with an install(app) method throws a TypeError.
  • Registering the same plugin again does nothing, as with app.use, even when the options differ.
  • An error thrown by the plugin's install stops that app from starting and is reported as a page error.

$i18n

Translate and format text in the browser. $i18n is the translation service of the nearest <c-i18n> that has the client attribute:

<c-i18n tag="section" client>
  <output v-text="$i18n.tr('my-app-status')"></output>
  <button @click="$i18n.switchLocale('cs-CZ')">Čeőtina</button>
</c-i18n>

Templates use $i18n inside such a <c-i18n>. Component JavaScript uses this.$i18n or component.$i18n. It is null outside a client <c-i18n>, and undefined when the app does not configure the i18n extension.

MemberMeaning
tr(message, values?, options?)Return loaded message text. Use { attr: "name" } for a Fluent attribute.
formatNamed number, percent, currency, date, time, datetime, relative-time, list, and unit formatters.
switchLocale(locale)Switch this provider's part of the page to another locale.
contextRead-only locale, fallback, direction, time-zone, and revision data.
statusRead-only loading state of the provider.
resolve(message, values?, options?)Return frozen { text, locale, direction, usedFallback } details.
parseStrict number and percent parsers.
ensureMessages(messages)Load one message ID, or a list, before looking up a message whose ID is computed at runtime.
subscribe(callback)Call back now and after each context change. Returns a function that stops the calls.
bind(options)Keep an element created by browser code translated. Returns refresh() and dispose().

See Browser i18n for $c-tr, message loading, and bind().