Skip to content

Using it without React

This page is mostly about campaign islands (streams, StreamText’s obligations, the choreographer). Reading it for fragments only? Skip to Fragments without a framework. createClient is the whole story there.

@sparkletree/core has no framework dependency. It speaks the network protocol, runs the same copy behaviour, and hands you state to render however you like. @sparkletree/react is a binding over it, not a superset.

Terminal window
npm install @sparkletree/core
import { openStream, decideChrome } from "@sparkletree/core";
const handle = openStream({
target: {
apiBase: "https://api.example.com",
organizationId: "org_123",
campaignId: "camp_123",
island: "hero",
context: { deviceType: "desktop", language: "en" },
},
initial: { headline: "Software that meets the moment" },
initialIsOnScreen: false,
onState: (state) => {
headlineEl.textContent = state.fields.headline.text;
},
});
const final = await handle.finished; // never rejects
handle.close(); // idempotent, safe to call anyway

initial is your fallback copy and is required in practice: it is what renders instead of anything adapted. At this level the hold arms as the request opens. @sparkletree/react arms it earlier, at component mount, and passes the remaining budget through holdMs, so one budget spans the whole wait. The fields go blank until the stream delivers, or until the budget hands your copy back: 2.5 seconds or holdMs, restarted once if the stream proves it is alive. Set initialIsOnScreen: true when that copy is already visible; the block then never blanks it and never types over it. Deltas buffer off-canvas and the copy changes once, composed, at *-done.

Reserve the space yourself while it holds. The choreographer is DOM-pure: it reports what the hold pulled off the canvas in state.held (per field, the copy that was actually painted) and leaves the drawing to you. Render that string hidden while field.text is "", or your section collapses for the hold’s duration and the page shifts when copy lands. This is the one thing StreamText does for you in React:

const reserve = state.held?.headline;
if (state.fields.headline.text === "" && reserve) {
headlineEl.textContent = reserve;
headlineEl.style.visibility = "hidden";
} else {
headlineEl.textContent = state.fields.headline.text;
headlineEl.style.visibility = "";
}

state.held is null when no hold armed this page view.

openStream returns { choreographer, finished, close }, where choreographer is the object holding the current copy state. Subscribe with onState, or with handle.choreographer.subscribe(fn).

Decide the disclosure label per field, never per stream

Section titled “Decide the disclosure label per field, never per stream”

If you show a visible disclosure mark, decide it per field:

const { marked } = decideChrome(state.fields.headline.source, prefersStandard);

Use field.source, not state.contentSource. The stream-level value is the strongest source seen anywhere in the response, and responses really are mixed: a generated headline can sit beside a body served from your own copy. Labelling every field by the stream-level value stamps “adapted for you” on text your copywriter wrote, which is the failure this design treats as worse than a missing label.

Read the visitor’s preference with viewerPrefersStandard() and write it with setViewerPrefersStandard(value).

The request tells the server the client owns the count, so if you do not record one, nothing does.

import { ImpressionRecorder, observeVisibility } from "@sparkletree/core";
const recorder = new ImpressionRecorder({
apiBase, organizationId, campaignId, variantId: final.variantId ?? undefined,
});
observeVisibility(element, () => {
recorder.record({
contentSource: final.contentSource,
chromeMarked: marked,
degraded: final.degraded,
island: "hero",
});
});

observeVisibility fires when the element has been 50% visible for 300ms and returns a cleanup function. ImpressionRecorder fires at most once. Wait until the copy has settled before recording, so the report describes what was read.

Send clicks and conversions with track(context, "click", { … }).

import { applyTheme, themeStyle } from "@sparkletree/core";
const restore = applyTheme(element, state.theme); // element first, then theme
const style = themeStyle(state.theme); // or as a style object

applyTheme returns a function that undoes it.

import { createClient } from "@sparkletree/core";
const client = createClient({ publishableKey: KEY, mintToken });
const { fields, warmth } = await client.fragments({
context: { page: "pricing", intent: "signup" },
fields: { cta: { kind: "cta", fallback: "Start your free trial" } },
});

fragments() resolves fast and never rejects. Same rule as above: render a disclosure label for any field whose source is not static.

  • Context collection. Time of day, device, language and referring origin are gathered by the React provider; here you build the context object yourself, with exactly the signals you want in it.
  • The swap rule for buttons. useFragments refuses to relabel a cta or label once your fallback is on screen. client.fragments() hands you the text and leaves the judgement to you.
  • The disclosure label. Opt-in on every path; render it from field.source (React exports InlineMark for the same job).
  • The first-paint fetch, if you want one. fetchContent is exported for it, and nothing calls it for you. Neither openStream nor the React path ever fetches /content. Your fallback is the whole first-paint story unless you fetch it yourself.

openStream takes onProtocolUnsupported({ requested, supported }), called when the server cannot speak the version this build asked for. Your first paint already stands by the time it fires; log it once, and do not retry. The React hook does not expose this.