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. The fields go blank until the stream delivers, or until the budget lands your copy: 2.5 seconds or holdMs, restarted once if the stream proves it is alive. Once your copy has landed it is final: the stream’s later copy for that field is dropped.

  • holdStartsInView: true starts the budget only when you call choreographer.slotInView(). The stream can open (and buffer) earlier; at that moment buffered copy lands, or the budget starts.
  • initialIsOnScreen: true says your copy is already visible: it is final for the view, so it is never blanked, typed over or replaced.
  • serverRendered: true says your initial is finished creative from the platform, not a fallback: also final, and nothing holds.
  • unfinishedCopy decides a field the stream was still writing when it ended. The default, keepUnlessClearlyError, keeps what was typed unless isClearlyErrorText says it is an error message, a stack or status text, raw markup or JSON, or a refusal; then the field gets your copy with crossfade: true.
  • openStream({ choreographer }) drives a choreographer you created (and started holding) earlier, so one budget spans a wait before the request.
  • fallbackDefinesSlots: true makes your fallback decide which fields exist: a field whose initial copy is empty is never shown, and the stream’s copy for it is dropped. Send each field’s fallback length as its budget with target.lengths (lengthBudgets(initial, ISLAND_COPY_FIELDS.hero)), which goes on the request as len.<field>.
  • choreographer.keepFallback(field) is for a renderer that measured copy that does not fit the space your fallback holds, before painting it: your fallback lands instead and is final. Pass fallback when initial is other copy, so it lands your fallback.
  • choreographer.checkDeadline() applies a hold that ran out while timers were throttled or frozen (a background tab, a page restored from the back/forward cache). Copy events call it first; call it on pageshow and visibilitychange too.

state.rewrites counts replacements of visible text and is always 0 from one stream. Render fades and cross-fades with COPY_MOTION_MS and COPY_MOTION_EASING.

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. useFragments never replaces a fallback once it has landed, and changes visible copy only on a change the visitor marked with markVisitorChange(). 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.