@sparkletree/core 0.11.2 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +18 -19
  2. package/dist/analytics.d.ts +20 -2
  3. package/dist/analytics.d.ts.map +1 -1
  4. package/dist/analytics.js +95 -7
  5. package/dist/analytics.js.map +1 -1
  6. package/dist/client.d.ts +30 -1
  7. package/dist/client.d.ts.map +1 -1
  8. package/dist/client.js +6 -6
  9. package/dist/client.js.map +1 -1
  10. package/dist/context.d.ts +8 -0
  11. package/dist/context.d.ts.map +1 -1
  12. package/dist/context.js +21 -0
  13. package/dist/context.js.map +1 -1
  14. package/dist/fragments.d.ts +34 -27
  15. package/dist/fragments.d.ts.map +1 -1
  16. package/dist/fragments.js +15 -8
  17. package/dist/fragments.js.map +1 -1
  18. package/dist/index.d.ts +6 -4
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +4 -2
  21. package/dist/index.js.map +1 -1
  22. package/dist/protocol.d.ts +12 -1
  23. package/dist/protocol.d.ts.map +1 -1
  24. package/dist/protocol.js +5 -2
  25. package/dist/protocol.js.map +1 -1
  26. package/dist/server.d.ts +144 -0
  27. package/dist/server.d.ts.map +1 -0
  28. package/dist/server.js +463 -0
  29. package/dist/server.js.map +1 -0
  30. package/dist/slots.d.ts +66 -0
  31. package/dist/slots.d.ts.map +1 -0
  32. package/dist/slots.js +102 -0
  33. package/dist/slots.js.map +1 -0
  34. package/dist/state.d.ts +282 -239
  35. package/dist/state.d.ts.map +1 -1
  36. package/dist/state.js +434 -413
  37. package/dist/state.js.map +1 -1
  38. package/dist/variant.d.ts +70 -1
  39. package/dist/variant.d.ts.map +1 -1
  40. package/dist/variant.js +85 -1
  41. package/dist/variant.js.map +1 -1
  42. package/dist/visitorContext.d.ts +99 -0
  43. package/dist/visitorContext.d.ts.map +1 -0
  44. package/dist/visitorContext.js +84 -0
  45. package/dist/visitorContext.js.map +1 -0
  46. package/package.json +5 -1
package/dist/state.js CHANGED
@@ -1,66 +1,112 @@
1
1
  /**
2
- * Copy choreography — the rewrite/settle state machine.
2
+ * Copy choreography — the hold/settle state machine.
3
3
  *
4
4
  * This is the part of the SDK that is not a fetch wrapper. The principle it
5
5
  * serves is a user-experience one, and everything else here follows from it:
6
6
  *
7
- * NO BAIT AND SWITCH. A visitor is never shown something and then has it
8
- * replaced, taken away, or pushed around — copy, image, video, layout,
9
- * all of it. So the space is reserved blank (sized by the copy it keeps
10
- * room for — never a skeleton, never a spinner) until there is something
11
- * final to show. If the stream delivers inside the budget, that is what
12
- * the visitor gets. If it is too slow or fails, the fallback is what they
13
- * get. Either way, that is the content for this page view. A stream that
14
- * arrives after the visitor already has something in front of them has
15
- * lost its slot: it is discarded from the canvas (it may still inform
16
- * provenance and analytics). The fallback is the failure path; it never
17
- * shows first and then gets replaced.
7
+ * TEXT A VISITOR CAN SEE IS NEVER REPLACED WHILE IT IS ON SCREEN.
18
8
  *
19
- * TEXT ON THE WIRE IS TEXT WE CAN SHOW. The first CHUNK takes the canvas,
20
- * whatever the `*-start` called itself. The delivery `mode` is not
21
- * consulted for hold release — an earlier revision released on
22
- * `mode: "generate"` and kept holding on `replay`, which re-derived from
23
- * a label what the arrival of actual text proves directly. A replay's
24
- * single flush still paints once, because one flush is genuinely what
25
- * arrived — not because it was held back.
9
+ * 1. When a copy slot comes into view and live copy is coming, the slot
10
+ * holds blank with its geometry reserved for a short budget
11
+ * (`COPY_HOLD_MS`), restarting once when the stream proves it is alive.
12
+ * 2. Stream text arriving inside the hold types into the blank slot (a
13
+ * cached replay's single flush lands whole). The fallback is never seen.
14
+ * 3. If the hold runs out with nothing delivered, the fallback lands once
15
+ * and is FINAL for this view: any later stream text for that field is
16
+ * dropped — never painted, never composed over it.
17
+ * 4. Art, background and chrome are never held; only text slots are.
26
18
  *
27
- * The hold is the mechanism: the canvas holds blank on the art for a budget —
28
- * a budget for the STORED FALLBACK, not for the stream.
19
+ * Every field is in exactly one of three conditions:
29
20
  *
30
- * KNOWN BUGS AGAINST THE PRINCIPLE, still in this file: a late stream's
31
- * finished text can land once over a shown fallback (the composed landing at
32
- * `*-done`; `rewrites` reaching 1), and fallback text that server HTML showed
33
- * before the hold could arm is tolerated rather than prevented. Neither is a
34
- * feature. Do not build on them, and do not read them as permission to show
35
- * something and then replace it.
21
+ * - blank: held (reserved) or simply empty, waiting for the stream;
22
+ * - live: stream text is on the canvas; later chunks EXTEND it (typing),
23
+ * and its `*-done` completes it — never replaces it;
24
+ * - final: copy the visitor can read that the stream does not own — the
25
+ * fallback after the hold, finished creative the platform rendered, or
26
+ * copy that was already on screen when the stream opened. Copy events
27
+ * for a final field are dropped.
36
28
  *
37
- * Reference implementation (the iframe shell, which must stay behaviourally
38
- * identical): `mosaic/services/rendererService.ts`, streamInitialCopyCreative.
29
+ * There is one exception to "never replaced", and it is isolated in one
30
+ * function: a sentence the stream was still WRITING when it failed. See
31
+ * `UnfinishedCopyPolicy`.
39
32
  */
40
33
  import { CONTENT_SOURCES } from "./wire.js";
34
+ import { warnDev } from "./devwarn.js";
41
35
  import { safeActionUrl } from "./safeUrl.js";
42
36
  export const COPY_FIELDS = ["greeting", "headline", "body", "cta"];
43
37
  /**
44
- * How long the canvas will hold blank waiting for the stream.
38
+ * How long a copy slot holds blank, waiting for live copy, before the
39
+ * fallback lands.
45
40
  *
46
- * Armed when the REQUEST opens (openStream calls streamOpened before the
47
- * fetch), so the fallback never paints ahead of a stream that is about to
48
- * replace it. The slow-connect worry that once argued for arming against the
49
- * connection instead is answered by the restart: `copyStart` restarts the
50
- * budget ONCE when the stream proves it is alive, so a slow link still gets a
51
- * full budget for delivery — without the blank window being extended per
52
- * field.
41
+ * The budget starts when the slot is in view (or when the stream opens, for
42
+ * a renderer that does not report visibility), and restarts ONCE when the
43
+ * stream proves it is alive (its first `*-start`). So the longest a slot can
44
+ * hold is `COPY_HOLD_MAX_MS`.
53
45
  */
54
46
  export const COPY_HOLD_MS = 2500;
47
+ /** The longest a slot can hold: one budget plus the one restart. */
48
+ export const COPY_HOLD_MAX_MS = 2 * COPY_HOLD_MS;
49
+ /**
50
+ * The pure-CSS failsafe for server-rendered reservations: if the page's
51
+ * JavaScript never takes control, reserved fallback copy becomes visible on
52
+ * its own after this long. Equal to the longest hold, so the failsafe can
53
+ * never reveal copy a running hold still owns.
54
+ */
55
+ export const RESERVE_FAILSAFE_MS = COPY_HOLD_MAX_MS;
56
+ /**
57
+ * The one motion grammar for copy: every fade and cross-fade runs for this
58
+ * long on `COPY_MOTION_EASING`. Typing has its own pace; nothing else moves.
59
+ */
60
+ export const COPY_MOTION_MS = 700;
61
+ /** The easing curve for every copy fade and cross-fade. */
62
+ export const COPY_MOTION_EASING = "cubic-bezier(0.16, 1, 0.3, 1)";
63
+ // Each pattern is something marketing copy does not contain. Deliberately
64
+ // narrow: a false positive replaces real copy the visitor was reading.
65
+ const ERROR_TEXT_PATTERNS = [
66
+ // An error message opening a line: "Error: …", "TypeError: …".
67
+ /(?:^|\n)\s*[A-Za-z]*(?:Error|Exception): /,
68
+ // A stack: a frame like "at fn (file.js:12:3)", or a Python traceback.
69
+ /\bat \S+ \(\S+:\d+:\d+\)/,
70
+ /Traceback \(most recent call last\)/,
71
+ // Status text: an HTTP status line or a 4xx/5xx reason phrase.
72
+ /\bHTTP\/\d(?:\.\d)? \d{3}\b/,
73
+ /\b[45]\d\d (?:Bad Request|Unauthorized|Forbidden|Not Found|Method Not Allowed|Request Timeout|Too Many Requests|Internal Server Error|Bad Gateway|Service Unavailable|Gateway Timeout)\b/i,
74
+ // Raw markup: an HTML/XML tag.
75
+ /<\/?[a-z][a-z0-9-]*(?:\s[^<>]*)?\/?>/i,
76
+ // Raw JSON: an object or array opening with a key, or a quoted key/value.
77
+ /^\s*[[{]\s*["{[]/,
78
+ /"[A-Za-z_][\w-]*"\s*:\s*["{[\d]/,
79
+ // A model refusal.
80
+ /\b(?:I'm sorry|I am sorry|I apologi[sz]e|As an AI\b|I(?:'m| am) unable to|I can(?:'|no)t (?:help|assist|comply|provide|create|write) )/i,
81
+ ];
82
+ /**
83
+ * True when `text` is very clearly not copy: it contains an error message, a
84
+ * stack or status text, raw markup or JSON, or a model refusal. Conservative
85
+ * by design — anything that could be ordinary copy returns false.
86
+ */
87
+ export function isClearlyErrorText(text) {
88
+ return ERROR_TEXT_PATTERNS.some((pattern) => pattern.test(text));
89
+ }
90
+ /**
91
+ * The default `UnfinishedCopyPolicy`: keep whatever the stream already typed,
92
+ * even an unfinished line. Only text that is clearly an error
93
+ * (`isClearlyErrorText`) is replaced, with one fade to the fallback.
94
+ */
95
+ export const keepUnlessClearlyError = ({ typed }) => isClearlyErrorText(typed) ? "fallback" : "keep";
55
96
  function emptyField(text = "", source = "static") {
56
97
  return { text, source, mode: null, typing: false, settled: false };
57
98
  }
99
+ /** A field holding copy the stream does not own: authored, static, at rest. */
100
+ function restingField(field, text) {
101
+ return { ...field, text, source: "static", mode: null, typing: false };
102
+ }
103
+ const SOURCE_RANK = { static: 0, cached: 1, generated: 2 };
58
104
  /**
59
105
  * Owns one island's copy for one page view. Fed wire events; emits states.
60
106
  *
61
107
  * Pure with respect to the DOM: it decides WHAT should be on screen and when,
62
108
  * and never touches how. That is what lets the same choreography drive React,
63
- * the iframe shell, and anything else without three copies of these rules.
109
+ * the iframe shell, and anything else without several copies of these rules.
64
110
  */
65
111
  export class CopyChoreographer {
66
112
  options;
@@ -69,59 +115,37 @@ export class CopyChoreographer {
69
115
  holdMs;
70
116
  setTimer;
71
117
  clearTimer;
72
- /**
73
- * Copy pulled off the canvas when the hold armed. Alive until the stream
74
- * TERMINATES (`done`/`failed`), not merely until the hold releases: any
75
- * field the stream never delivers is restored from here, because a field
76
- * the stream never took must end on its fallback — never on the blank the
77
- * hold itself painted.
78
- */
118
+ releaseOn;
119
+ unfinishedPolicy;
120
+ // The fallback for each field as reserved by the hold (null when no hold).
79
121
  heldCopy = null;
122
+ // The hold budget. Runs from view until it expires or the stream ends; it
123
+ // outlives a chunk-driven release so no slot stays blank past it.
80
124
  holdTimer = null;
81
- /**
82
- * The budget may restart ONCE per hold episode. Without a cap, a server
83
- * emitting a `*-start` per field would extend the blank window by a full
84
- * budget per field — proof of life is proof once, and a blank canvas is a
85
- * cost the visitor pays, so it is not for sale twice.
86
- */
125
+ // One restart per view: proof of life is proof once.
87
126
  holdRestarted = false;
88
- /** Text accumulated from the wire while the hold is still on. */
127
+ // When the hold budget started (clock time, minus any time already spent),
128
+ // so the absolute maximum holds across the restart.
129
+ holdStartedAt = null;
130
+ // When the running hold timer is due (clock time), so an expiry a
131
+ // throttled or frozen timer has not run yet is still honoured.
132
+ holdDueAt = null;
133
+ now;
134
+ // Is the slot in view (has the hold budget been allowed to start)?
135
+ inView;
136
+ // Text from the wire, per field. While holding it is a buffer; once live
137
+ // it mirrors what the field shows.
89
138
  pending = {};
90
- /** True once the machine has committed a change to the first paint. */
91
- rewriteCommitted = false;
92
- /**
93
- * Fields whose on-canvas text the visitor is already reading: restored
94
- * fallback, `serverRendered` creative, or initial copy that was on screen
95
- * when the stream opened (`initialIsOnScreen`). Live deltas buffer
96
- * off-canvas for these. That the buffer then lands at `*-done` is a known
97
- * bug against the file header's principle: shown text should stay.
98
- *
99
- * Gated on ON-SCREEN, never on non-empty: an earlier revision seeded it
100
- * with every non-empty field at first paint, which is every field of
101
- * every integration (fallback is required by doctrine), so `typing` was
102
- * never true and paced streaming was dead. A held field's canvas is
103
- * blank, and typing into blank is the entrance — that path must never
104
- * seed here.
105
- */
106
- fallbackOnCanvas = new Set();
107
- /** Fields the stream has opened with a `*-start` (or that received a chunk). */
139
+ // Fields whose visible text the stream does not own. Copy events dropped.
140
+ final = new Set();
141
+ // Fields showing stream text.
142
+ live = new Set();
143
+ // Fields the stream opened (`*-start` or a chunk) and closed (`*-done`).
108
144
  started = new Set();
109
- /**
110
- * First-paint copy, kept for the terminal restore: a field the stream
111
- * opened but never closed must end on its WHOLE fallback, never on the
112
- * half-typed fragment the dead stream left (`"Meet the web that"`). The
113
- * hold's `heldCopy` only exists when the hold armed; typing over on-screen
114
- * fallback needs this snapshot instead.
115
- */
116
- initialCopy = {};
117
- /**
118
- * Fields the stream has closed with a `*-done`, including closes that
119
- * arrived while the hold was still on and so never reached the canvas at the
120
- * time. Consulted when the copy finally paints, so `settled` describes the
121
- * field rather than the route it took.
122
- */
123
145
  closed = new Set();
124
- /** Set by dispose(): async completions (image decode) must not patch after it. */
146
+ // First-paint copy: the fallback for a field the hold never reserved.
147
+ initialCopy = {};
148
+ // Set by dispose(): async completions (image decode) must not patch after it.
125
149
  disposed = false;
126
150
  constructor(options = {}) {
127
151
  this.options = options;
@@ -129,10 +153,25 @@ export class CopyChoreographer {
129
153
  this.setTimer = options.setTimeout ?? ((fn, ms) => setTimeout(fn, ms));
130
154
  this.clearTimer =
131
155
  options.clearTimeout ?? ((handle) => clearTimeout(handle));
156
+ this.releaseOn = options.releaseOn ?? "text";
157
+ this.now = options.now ?? (() => Date.now());
158
+ this.unfinishedPolicy = options.unfinishedCopy ?? keepUnlessClearlyError;
159
+ this.inView = !options.holdStartsInView;
132
160
  const fields = {};
161
+ const finishedSource = options.serverRendered ? (options.initialSource ?? "static") : "static";
162
+ let seededSource = "static";
133
163
  for (const field of COPY_FIELDS) {
134
- fields[field] = emptyField(options.initial?.[field] ?? "");
135
- this.initialCopy[field] = options.initial?.[field] ?? "";
164
+ const text = options.initial?.[field] ?? "";
165
+ const source = options.serverRendered
166
+ ? (options.initialSources?.[field] ?? finishedSource)
167
+ : "static";
168
+ fields[field] = emptyField(text, text !== "" ? source : "static");
169
+ if (options.serverRendered && text !== "")
170
+ fields[field].settled = true;
171
+ if (SOURCE_RANK[fields[field].source] > SOURCE_RANK[seededSource]) {
172
+ seededSource = fields[field].source;
173
+ }
174
+ this.initialCopy[field] = text;
136
175
  }
137
176
  this.state = {
138
177
  phase: "idle",
@@ -146,9 +185,9 @@ export class CopyChoreographer {
146
185
  ctaStyle: null,
147
186
  ctaAction: null,
148
187
  meta: null,
149
- contentSource: "static",
188
+ contentSource: seededSource,
150
189
  degraded: null,
151
- variantId: null,
190
+ variantId: options.initialVariantId ?? null,
152
191
  error: null,
153
192
  rewrites: 0,
154
193
  held: null,
@@ -167,10 +206,8 @@ export class CopyChoreographer {
167
206
  listener(this.state);
168
207
  }
169
208
  catch {
170
- // Swallowed on purpose. A subscriber throwing is the SUBSCRIBER's bug;
171
- // letting it propagate would tear down the stream for every other
172
- // listener and break the customer's page from inside a render effect.
173
- // Same isolation the audio emitter applies (audio.ts createEmitter).
209
+ // A subscriber throwing is the subscriber's bug; letting it propagate
210
+ // would tear down the stream for every other listener.
174
211
  }
175
212
  }
176
213
  }
@@ -188,286 +225,331 @@ export class CopyChoreographer {
188
225
  hasInitialCopy() {
189
226
  return COPY_FIELDS.some((field) => this.state.fields[field].text.trim() !== "");
190
227
  }
228
+ anyPending() {
229
+ return COPY_FIELDS.some((field) => (this.pending[field] ?? "") !== "");
230
+ }
231
+ isTerminal() {
232
+ return this.state.phase === "settled" || this.state.phase === "failed";
233
+ }
234
+ fallbackFor(field) {
235
+ return this.heldCopy?.[field] ?? this.options.fallback?.[field] ?? this.initialCopy[field] ?? "";
236
+ }
191
237
  /**
192
- * Call when the stream request has been opened.
238
+ * Call when live copy is coming: when the stream request opens, or earlier
239
+ * when a renderer already knows it will (a stream waiting on a shared
240
+ * variant). Idempotent.
193
241
  *
194
- * Arms the hold if — and only if — there is initial copy on the canvas the
195
- * visitor has not seen yet. That is the intended path: the hold arms before
196
- * the first frame, so the fallback never shows ahead of the stream. Copy
197
- * the visitor is already looking at (`initialIsOnScreen`, `serverRendered`)
198
- * is left alone — taking it away now would be the bait and switch.
242
+ * Arms the hold when there is fallback copy the visitor has not seen. Copy
243
+ * the visitor can already see (`initialIsOnScreen`) or finished creative
244
+ * (`serverRendered`) is final instead: nothing to hold, nothing to replace.
199
245
  */
200
246
  streamOpened() {
201
247
  if (this.state.phase !== "idle")
202
248
  return;
203
- const onScreen = this.options.initialIsOnScreen || this.options.serverRendered;
204
- if (this.hasInitialCopy() && !onScreen) {
249
+ // A field the fallback does not have has no slot: it is never shown.
250
+ if (this.options.fallbackDefinesSlots && this.hasInitialCopy()) {
251
+ for (const field of COPY_FIELDS) {
252
+ if (this.state.fields[field].text.trim() === "")
253
+ this.final.add(field);
254
+ }
255
+ }
256
+ if (this.options.initialIsOnScreen || this.options.serverRendered) {
257
+ const whole = this.options.serverRendered === true && this.options.initialIsComplete === true;
258
+ for (const field of COPY_FIELDS) {
259
+ if (whole || this.state.fields[field].text.trim() !== "")
260
+ this.final.add(field);
261
+ }
262
+ this.patch({ phase: "live" });
263
+ return;
264
+ }
265
+ if (this.hasInitialCopy()) {
205
266
  this.heldCopy = {};
206
- this.holdRestarted = false;
207
267
  const blanked = {};
208
268
  for (const field of COPY_FIELDS) {
209
269
  this.heldCopy[field] = this.state.fields[field].text;
210
270
  blanked[field] = { ...this.state.fields[field], text: "" };
211
271
  }
212
- // `held` rides every later state spread untouched: the reservation
213
- // stays readable for as long as the hold's snapshot is alive.
214
272
  this.state = { ...this.state, phase: "holding", fields: blanked, held: this.heldCopy };
215
- this.holdTimer = this.setTimer(() => this.onHoldExpired(), this.holdMs);
273
+ if (this.inView)
274
+ this.armHold();
216
275
  this.emit();
217
276
  return;
218
277
  }
219
- // Nothing to HOLD: either the canvas is genuinely blank (so live typing IS
220
- // the entrance) or the visitor is already reading the copy (so it is not
221
- // taken away).
222
- //
223
- // Copy the visitor is reading is not typed over either: deltas aimed at
224
- // it buffer off-canvas, whether the words are campaign creative or the
225
- // developer's fallback. If the stream dies mid-field the terminal
226
- // handlers keep (or repaint) the WHOLE fallback rather than stranding
227
- // the reader on a fragment. That the buffer still lands once at `*-done`
228
- // is a known bug (file header), not the intent.
229
- //
230
- // The seeding is gated on ON-SCREEN, never on non-empty: an earlier
231
- // revision seeded every non-empty field here, and since `fallback` is
232
- // required by doctrine and paints before the stream opens, that made
233
- // `typing` permanently false and killed paced streaming in every real
234
- // integration. The held path stays live typing: its canvas is blank,
235
- // and typing into blank is the entrance, not churn.
236
- if (onScreen) {
237
- for (const field of COPY_FIELDS) {
238
- if (this.state.fields[field].text.trim() !== "")
239
- this.fallbackOnCanvas.add(field);
240
- }
241
- }
278
+ // Nothing to hold: the canvas is genuinely blank, so live typing IS the
279
+ // entrance.
242
280
  this.patch({ phase: "live" });
243
281
  }
244
282
  /**
245
- * Hold expired without the stream taking the canvas.
283
+ * Call when the slot is in view (already in view at mount counts). Only
284
+ * meaningful with `holdStartsInView`; otherwise the slot is in view from
285
+ * the start and this is a no-op.
246
286
  *
247
- * Prefer whatever the stream actually delivered; the stored copy is the last
248
- * resort, never a first draft.
287
+ * At that moment copy the stream already delivered takes the canvas —
288
+ * finished fields land whole, unfinished ones type from the buffer — and
289
+ * with nothing delivered the hold budget starts.
249
290
  */
250
- onHoldExpired() {
251
- this.holdTimer = null;
291
+ slotInView() {
292
+ if (this.inView)
293
+ return;
294
+ this.inView = true;
252
295
  if (this.state.phase !== "holding")
253
296
  return;
254
- if (COPY_FIELDS.some((field) => (this.pending[field] ?? "") !== "")) {
255
- this.releaseHoldToLive(true);
297
+ if (this.releaseOn === "text" && this.anyPending()) {
298
+ this.releaseHold("view");
299
+ this.armHold();
256
300
  return;
257
301
  }
258
- this.restoreHeld();
302
+ this.armHold();
259
303
  }
260
- /**
261
- * The stream is over and the canvas is final.
262
- *
263
- * `readSseStream` keeps draining after a terminal event, and the parser's
264
- * flush can emit a trailing frame, so late copy events are reachable rather
265
- * than theoretical. Without this guard a post-`failed` chunk left
266
- * `typing: true` forever — a caret animating on a dead stream — and a
267
- * post-`done` chunk+done replaced settled text WITHOUT incrementing
268
- * `rewrites`, so the doctrine counter read 1 while the reader saw two
269
- * changes. Everything after the terminal event is dropped.
270
- */
271
- isTerminal() {
272
- return this.state.phase === "settled" || this.state.phase === "failed";
304
+ /** Milliseconds left before the hold's absolute maximum (2 × budget). */
305
+ absoluteRemaining() {
306
+ if (this.holdStartedAt === null)
307
+ return 2 * this.holdMs;
308
+ return Math.max(0, this.holdStartedAt + 2 * this.holdMs - this.now());
309
+ }
310
+ armHold() {
311
+ if (this.holdTimer !== null || this.disposed)
312
+ return;
313
+ if (this.holdStartedAt === null) {
314
+ // Time already spent holding (a reservation that painted before this
315
+ // took over) counts against the budget and the maximum.
316
+ this.holdStartedAt = this.now() - Math.max(0, this.options.holdElapsedMs ?? 0);
317
+ }
318
+ const firstBudget = Math.max(0, this.holdStartedAt + this.holdMs - this.now());
319
+ this.startHoldTimer(firstBudget);
320
+ }
321
+ startHoldTimer(ms) {
322
+ this.holdDueAt = this.now() + ms;
323
+ this.holdTimer = this.setTimer(() => this.onHoldExpired(), ms);
273
324
  }
274
325
  clearHoldTimer() {
275
326
  if (this.holdTimer !== null) {
276
327
  this.clearTimer(this.holdTimer);
277
328
  this.holdTimer = null;
278
329
  }
330
+ this.holdDueAt = null;
279
331
  }
280
332
  /**
281
- * Hand the canvas to the live stream: what has accumulated paints at once,
282
- * so the rest can stream in behind it.
283
- *
284
- * `restoreUndelivered` distinguishes the two callers. On a chunk-driven
285
- * release the stream is alive and delivering, so undelivered fields stay
286
- * blank — typing into a blank canvas is their entrance, and `heldCopy`
287
- * outlives the hold so `done`/`failed` restore any field the stream never
288
- * takes. On budget EXPIRY the stream has sputtered: whatever it delivered
289
- * paints, and every undelivered field gets its fallback back now, because
290
- * blank is never the resting state the budget is allowed to buy.
333
+ * Apply a hold expiry whose time has already passed but whose timer has
334
+ * not run: a background tab throttles timers, and a page restored from the
335
+ * back/forward cache resumes with them overdue. Copy events call this
336
+ * first, so text arriving after the deadline is never shown in place of
337
+ * the fallback. Renderers call it when the page is shown again. Idempotent.
291
338
  */
292
- releaseHoldToLive(restoreUndelivered) {
339
+ checkDeadline() {
340
+ if (this.holdTimer === null || this.holdDueAt === null || this.disposed)
341
+ return;
342
+ if (this.now() < this.holdDueAt)
343
+ return;
293
344
  this.clearHoldTimer();
345
+ this.onHoldExpired();
346
+ }
347
+ /**
348
+ * The budget ran out. While still holding: delivered copy takes the canvas
349
+ * and keeps streaming, and every field with nothing delivered gets its
350
+ * fallback — final. After a chunk already took the canvas: fields still
351
+ * blank get their fallback, final; fields the stream is writing continue.
352
+ */
353
+ onHoldExpired() {
354
+ this.holdTimer = null;
355
+ this.holdDueAt = null;
356
+ if (this.state.phase === "holding") {
357
+ if (this.anyPending())
358
+ this.releaseHold("expiry");
359
+ else
360
+ this.restoreHeld();
361
+ return;
362
+ }
363
+ if (this.state.phase !== "live" || !this.heldCopy)
364
+ return;
365
+ const fields = { ...this.state.fields };
366
+ let changed = false;
367
+ for (const field of COPY_FIELDS) {
368
+ if (this.live.has(field) || this.final.has(field))
369
+ continue;
370
+ if (fields[field].text !== "")
371
+ continue;
372
+ fields[field] = restingField(fields[field], this.fallbackFor(field));
373
+ this.final.add(field);
374
+ changed = true;
375
+ }
376
+ if (!changed)
377
+ return;
378
+ this.state = { ...this.state, fields };
379
+ this.emit();
380
+ }
381
+ /**
382
+ * Hand the held canvas to the stream. Delivered fields take their text:
383
+ * finished ones whole, unfinished ones typing on. `reason` decides the
384
+ * rest:
385
+ *
386
+ * - "chunk": the first text arrived in time. Undelivered fields stay
387
+ * blank and reserved; the budget keeps running for them.
388
+ * - "view": the slot just came into view with copy already buffered.
389
+ * - "expiry": the budget ran out; undelivered fields get their fallback,
390
+ * final.
391
+ * - "terminal": the stream ended; the terminal handler settles the rest.
392
+ */
393
+ releaseHold(reason) {
394
+ if (reason !== "chunk" && reason !== "view")
395
+ this.clearHoldTimer();
294
396
  const fields = { ...this.state.fields };
295
- let paintedStreamText = false;
296
397
  for (const field of COPY_FIELDS) {
297
398
  const text = this.pending[field];
298
399
  if (text !== undefined && text !== "") {
299
- fields[field] = { ...fields[field], text, settled: this.closed.has(field) };
300
- this.fallbackOnCanvas.delete(field);
301
- if (text !== (this.heldCopy?.[field] ?? ""))
302
- paintedStreamText = true;
400
+ const closed = this.closed.has(field);
401
+ fields[field] = {
402
+ ...fields[field],
403
+ text,
404
+ settled: closed,
405
+ // A chunk-driven entrance types per its declared mode; buffered
406
+ // text that is still being written types on from the buffer.
407
+ typing: closed ? false : reason === "chunk" ? fields[field].typing : true,
408
+ };
409
+ this.live.add(field);
303
410
  continue;
304
411
  }
305
- if (!restoreUndelivered)
306
- continue;
307
- const held = this.heldCopy?.[field];
308
- if (held !== undefined && held !== "") {
309
- fields[field] = { ...fields[field], text: held };
310
- this.fallbackOnCanvas.add(field);
412
+ if (reason === "expiry") {
413
+ fields[field] = restingField(fields[field], this.fallbackFor(field));
414
+ this.final.add(field);
311
415
  }
312
416
  }
313
- // Restoring fallback is not a rewrite — nothing the reader saw changed
314
- // versus first paint. Stream text landing is the one rewrite.
315
- if (paintedStreamText)
316
- this.commitRewrite();
317
417
  this.state = { ...this.state, phase: "live", fields };
318
418
  this.emit();
319
419
  }
320
- /** Budget spent with nothing delivered: the fallback comes back, composed. */
420
+ /** Budget spent with nothing delivered: every fallback lands once, final. */
321
421
  restoreHeld() {
322
422
  this.clearHoldTimer();
323
- if (!this.heldCopy) {
324
- this.patch({ phase: "live" });
325
- return;
326
- }
327
423
  const fields = { ...this.state.fields };
328
424
  for (const field of COPY_FIELDS) {
329
- const text = this.heldCopy[field];
330
- if (text !== undefined)
331
- fields[field] = { ...fields[field], text };
332
- if (text !== undefined && text !== "")
333
- this.fallbackOnCanvas.add(field);
425
+ fields[field] = restingField(fields[field], this.fallbackFor(field));
426
+ this.final.add(field);
334
427
  }
335
- this.heldCopy = null;
336
428
  this.state = { ...this.state, phase: "live", fields };
337
429
  this.emit();
338
430
  }
339
- /**
340
- * Record that the first paint has been replaced. Restoring the SAME fallback
341
- * text is not a rewrite — nothing on screen changed — which is why every
342
- * caller commits only when STREAM text actually painted, and restoreHeld
343
- * never commits at all.
344
- */
345
- commitRewrite() {
346
- if (this.rewriteCommitted)
347
- return;
348
- this.rewriteCommitted = true;
349
- this.state = { ...this.state, rewrites: this.state.rewrites + 1 };
350
- }
351
- /**
352
- * Terminal honesty pass over provenance.
353
- *
354
- * A field still in `fallbackOnCanvas` when the stream ends is showing the
355
- * customer's AUTHORED text: its buffered deltas never landed. Its `source`
356
- * still says `generated` because `*-start` declared an intent the stream
357
- * then failed to deliver — and `field.source` feeds every provenance
358
- * surface: the `data-content-source` stamp, `decideChrome` (and any opt-in
359
- * `InlineMark` the integrator renders from it), `$group.marked`, and the
360
- * impression beacon's `chromeMarked`. Leaving it would claim "adapted" on
361
- * copy nobody adapted, on all of them at once.
362
- *
363
- * Provenance must describe what is on screen, not what the server meant to
364
- * send. Under-claiming adapted copy costs credit; claiming authored copy is
365
- * adapted is a false statement, and that is the worse of the two.
366
- *
367
- * `fallbackOnCanvas` is NOT the whole set. A chunk-driven release leaves
368
- * undelivered fields blank — restoring them mid-stream would stage the
369
- * wipe-and-retype this machine exists to prevent — so they never enter that
370
- * set, and are instead restored from `heldCopy` by the terminal handlers.
371
- * That is the likeliest degradation there is: the server opens a field,
372
- * then times out. Demoting only `fallbackOnCanvas` left exactly that path
373
- * rendering "adapted for you" over the customer's own sentence.
374
- *
375
- * So callers pass the fields they restored, and both sets are demoted.
376
- */
377
- demoteUndeliveredProvenance(fields, restoredFromHeld = []) {
378
- for (const field of new Set([...this.fallbackOnCanvas, ...restoredFromHeld])) {
379
- fields[field] = { ...fields[field], source: "static", mode: null };
380
- }
381
- }
382
431
  /** True once any field on canvas actually carries adapted copy. */
383
432
  hasAdaptedCopyOnCanvas(fields) {
384
433
  return COPY_FIELDS.some((field) => fields[field].text.trim() !== "" && fields[field].source !== "static");
385
434
  }
435
+ /** Copy events that arrive before the stream was opened open it first. */
436
+ ensureOpened() {
437
+ if (this.state.phase === "idle")
438
+ this.streamOpened();
439
+ }
386
440
  // --- wire events -----------------------------------------------------------
387
441
  /**
388
- * A field opened. Proof of life, and provenance — nothing more.
389
- *
390
- * The canvas changes hands on the first CHUNK, not here: the delivery mode
391
- * is recorded on the field but no longer consulted for hold release. All a
392
- * start does while holding is RESTART the fallback budget, because the
393
- * stream has just proven it is alive and the old budget was measuring a
394
- * connection that had not connected yet.
442
+ * A field opened: proof of life, and provenance. Restarts the hold budget
443
+ * once if it is running. Dropped for a final field.
395
444
  */
396
445
  copyStart(field, mode, source) {
446
+ if (this.isTerminal())
447
+ return;
448
+ this.ensureOpened();
449
+ this.checkDeadline();
450
+ if (this.final.has(field))
451
+ return;
397
452
  this.started.add(field);
398
- // A caret is only honest when deltas will actually land on canvas; deltas
399
- // aimed at on-screen copy (or restored fallback) buffer off-canvas
400
- // instead.
401
- const willType = mode === "generate" && !this.fallbackOnCanvas.has(field);
402
- this.patchField(field, { mode, source, typing: willType, settled: false });
453
+ this.patchField(field, { mode, source, typing: mode === "generate", settled: false });
403
454
  this.promoteContentSource(source);
404
- if (this.state.phase !== "holding")
405
- return;
406
- // One restart buys the freshly-proven stream a full budget; further
407
- // starts do not extend the blank window past that cap.
408
- if (this.holdRestarted)
455
+ if (this.holdTimer === null || this.holdRestarted)
409
456
  return;
410
457
  this.holdRestarted = true;
411
458
  this.clearHoldTimer();
412
- this.holdTimer = this.setTimer(() => this.onHoldExpired(), this.holdMs);
459
+ // A fresh budget, but never past the absolute maximum.
460
+ this.startHoldTimer(Math.min(this.holdMs, this.absoluteRemaining()));
413
461
  }
414
462
  /**
415
- * A delta landed. Text on the wire is text we can show: the first chunk
416
- * takes the canvas away from the pending fallback, whatever the start
417
- * called itself. While the field shows fallback copy the visitor can see,
418
- * deltas buffer off-canvas and never paint delta by delta. (That the
419
- * buffer lands once at `*-done` is a known bug, file header: shown
420
- * fallback should be final.)
463
+ * A delta landed. While holding (and in view) the first text takes the
464
+ * canvas; on a live field it extends the text. Dropped for a final field.
421
465
  */
422
466
  copyChunk(field, text) {
423
467
  if (this.isTerminal())
424
468
  return;
425
- // A chunk without its `*-start` is a contract violation, but the terminal
426
- // restore must still know this field was taken — a stranded fragment is a
427
- // stranded fragment whichever event painted it.
469
+ this.ensureOpened();
470
+ this.checkDeadline();
471
+ if (this.final.has(field))
472
+ return;
428
473
  this.started.add(field);
429
474
  this.pending[field] = (this.pending[field] ?? "") + text;
430
475
  if (this.state.phase === "holding") {
431
- // The release paints everything pending, this chunk included.
432
- this.releaseHoldToLive(false);
476
+ if (this.inView && this.releaseOn === "text")
477
+ this.releaseHold("chunk");
433
478
  return;
434
479
  }
435
- if (this.fallbackOnCanvas.has(field))
436
- return;
437
- // Commit BEFORE the emitting patch: subscribers must see the counter and
438
- // the text move together, not the counter one event late.
439
- this.commitRewrite();
440
- this.patchField(field, { text: this.pending[field], typing: true });
480
+ this.live.add(field);
481
+ // Live typing for a generate field; a replay's flush lands whole.
482
+ this.patchField(field, {
483
+ text: this.pending[field],
484
+ typing: this.state.fields[field].mode === "generate",
485
+ });
441
486
  }
442
487
  /**
443
- * A field closed. `text` is authoritative — a client that missed chunks can
444
- * paint this alone, which is what makes chunk loss a cosmetic problem
445
- * instead of a correctness one.
488
+ * A field closed. Its text completes what the field shows: it lands whole
489
+ * on a blank field and finishes a typed one. It never replaces different
490
+ * visible text — if it does not extend what was typed, the typed text
491
+ * stands (and development builds warn). For a final field this only marks
492
+ * it settled.
446
493
  */
447
494
  copyDone(field, text) {
448
495
  if (this.isTerminal())
449
496
  return;
450
- this.pending[field] = text;
451
- // Record the close BEFORE the hold's early return. A replay that lands
452
- // entirely inside the hold used to leave `settled: false` forever: this
453
- // returned before patching, and the terminal handlers only cleared
454
- // `typing`. `settled` is public API and a documented styling hook, so a
455
- // field whose text is final must say so however it arrived.
497
+ this.ensureOpened();
498
+ this.checkDeadline();
456
499
  this.closed.add(field);
457
- if (this.state.phase === "holding")
500
+ if (this.final.has(field)) {
501
+ this.patchField(field, { settled: true, typing: false });
458
502
  return;
459
- this.fallbackOnCanvas.delete(field);
460
- // Landing text identical to what is on canvas changes nothing a reader
461
- // can see, so it spends no rewrite.
462
- if (text !== this.state.fields[field].text)
463
- this.commitRewrite();
464
- this.patchField(field, { text, typing: false, settled: true });
503
+ }
504
+ this.started.add(field);
505
+ if (this.state.phase === "holding") {
506
+ this.pending[field] = text;
507
+ if (this.inView && this.releaseOn === "text" && text !== "")
508
+ this.releaseHold("chunk");
509
+ return;
510
+ }
511
+ const current = this.state.fields[field].text;
512
+ let next = text;
513
+ if (current !== "" && !text.startsWith(current)) {
514
+ next = current;
515
+ warnDev(`copy:done-diverged:${field}`, `the stream closed "${field}" with text that does not continue what it ` +
516
+ `typed. The typed text stays on screen: visible copy is never replaced.`);
517
+ }
518
+ this.pending[field] = next;
519
+ if (next !== "")
520
+ this.live.add(field);
521
+ this.patchField(field, { text: next, typing: false, settled: true });
522
+ }
523
+ /**
524
+ * The renderer will not show this field's copy: it does not fit the slot
525
+ * its fallback reserved. The fallback lands instead and is final for the
526
+ * view; later copy for the field is dropped. Call it before the copy is
527
+ * painted (a renderer measures before the browser paints), never for text
528
+ * the visitor has seen.
529
+ */
530
+ keepFallback(field) {
531
+ if (this.disposed)
532
+ return;
533
+ const fallback = this.fallbackFor(field);
534
+ if (this.final.has(field) && this.state.fields[field].text === fallback)
535
+ return;
536
+ this.final.add(field);
537
+ this.live.delete(field);
538
+ this.pending[field] = "";
539
+ const fields = { ...this.state.fields };
540
+ fields[field] = { ...restingField(fields[field], fallback), settled: true };
541
+ this.state = {
542
+ ...this.state,
543
+ fields,
544
+ contentSource: this.hasAdaptedCopyOnCanvas(fields) ? this.state.contentSource : "static",
545
+ };
546
+ this.emit();
465
547
  }
466
548
  // Terminal is terminal for the ready events too, not just copy: a trailing
467
- // cta-ready or theme-ready after `done` (reachable via the parser flush)
468
- // must not restyle, re-action, or recolor a settled creative the impression
469
- // already recorded. `backgroundReady` gates at ENTRY only — its decode-wait
470
- // patch lands after settle by design.
549
+ // cta-ready or theme-ready after `done` must not restyle, re-action, or
550
+ // recolor a settled creative the impression already recorded.
551
+ // `backgroundReady` gates at ENTRY only — its decode-wait patch lands after
552
+ // settle by design.
471
553
  themeReady(colors, sources) {
472
554
  if (this.isTerminal())
473
555
  return;
@@ -483,22 +565,15 @@ export class CopyChoreographer {
483
565
  }
484
566
  /**
485
567
  * Absence of this event means KEEP THE BAKED THEME WASH. It does not mean
486
- * clear the art layer — the server omits it when there is no creative image,
487
- * and a client that cleared on absence would blank the hero on exactly the
488
- * campaigns that never had a picture.
568
+ * clear the art layer — the server omits it when there is no creative image.
489
569
  *
490
- * The patch waits for the browser to DECODE the image first: consumers
491
- * render this state directly, and a backgroundImage that lands before
492
- * pixels exist paints the already-flipped theme against the old (or blank)
493
- * art — the contrast tear. On failure the patch still lands (the consumer's
494
- * own loading treatment owns that case); outside a browser it is immediate.
570
+ * The patch waits for the browser to DECODE the image first, so a new
571
+ * background never paints before its pixels exist. On failure the patch
572
+ * still lands; outside a browser it is immediate.
495
573
  */
496
574
  backgroundReady(bgImage) {
497
575
  if (!bgImage || this.isTerminal())
498
576
  return;
499
- // The decode wait is async: by the time onload fires the consumer may
500
- // have disposed this choreographer, and patching then would resurrect
501
- // state the owner already tore down.
502
577
  const apply = () => {
503
578
  if (this.disposed)
504
579
  return;
@@ -546,153 +621,100 @@ export class CopyChoreographer {
546
621
  this.copyDone("cta", text);
547
622
  }
548
623
  /**
549
- * Terminal. Anything still held paints now, once, composed — the last
550
- * chance for the stream's copy to land as a single deliberate change rather
551
- * than being dropped because the budget happened to outlive the stream.
624
+ * Settle every field at the end of the stream. Final and closed fields stay
625
+ * as they are; a field that never painted gets its fallback; a field the
626
+ * stream was still writing goes through the `UnfinishedCopyPolicy`.
552
627
  */
553
- done(meta) {
554
- // Terminal is terminal: a trailing `done` after `error` (or a duplicate
555
- // `done` — the parser's flush can re-emit a trailing frame) must not
556
- // re-open a settled stream or rebrand a failure as a success.
557
- if (this.isTerminal())
558
- return;
559
- if (this.state.phase === "holding") {
560
- if (COPY_FIELDS.some((field) => (this.pending[field] ?? "") !== "")) {
561
- // The blank-field restore below handles the undelivered fields.
562
- this.releaseHoldToLive(false);
628
+ settleFields() {
629
+ if (this.state.phase === "idle") {
630
+ // Ended before it opened: whatever is on the canvas is the copy.
631
+ for (const field of COPY_FIELDS) {
632
+ if (this.state.fields[field].text.trim() !== "")
633
+ this.final.add(field);
563
634
  }
564
- else {
635
+ }
636
+ else if (this.state.phase === "holding") {
637
+ if (this.anyPending())
638
+ this.releaseHold("terminal");
639
+ else
565
640
  this.restoreHeld();
566
- }
567
641
  }
568
642
  this.clearHoldTimer();
569
643
  const fields = { ...this.state.fields };
570
- const restored = [];
571
644
  for (const field of COPY_FIELDS) {
572
- fields[field] = {
573
- ...fields[field],
574
- typing: false,
575
- settled: fields[field].settled || this.closed.has(field),
576
- };
577
- // A field the stream never PAINTED ends on its fallback, never on the
578
- // blank the hold painted. Not a rewrite: nothing the reader saw
579
- // changed versus first paint.
580
- //
581
- // "Unfinished" is `started && !closed` — the SAME predicate `failed()`
582
- // uses, or the two terminal paths disagree about what counts as
583
- // delivered. Testing only for blank text missed the case where the
584
- // fallback was ALREADY ON SCREEN (the hydrated `"use client"` path,
585
- // where server HTML paints the fallback text — a render-path defect
586
- // this machine tolerates): the field was neither held nor blank, so it
587
- // was neither
588
- // restored nor demoted, and ended carrying `source: "generated"` over
589
- // the customer's own sentence.
590
- //
591
- // But an unfinished field with VISIBLE streamed text is different: the
592
- // reader is mid-way through real generated copy, and wiping it back to
593
- // the fallback at `done` is exactly the mid-read yank the doctrine
594
- // forbids. A lost close frame stays cosmetic — the partial text
595
- // stands, its provenance true, and the field settles where it is.
596
- const held = this.heldCopy?.[field] ?? this.initialCopy[field];
597
- const unfinished = this.started.has(field) && !this.closed.has(field);
598
- const neverPainted = fields[field].text === "" || (unfinished && fields[field].text === held);
599
- if (neverPainted && held !== undefined && held !== "") {
600
- // Restored copy is final resting copy: settled, whatever the stream
601
- // failed to close.
602
- fields[field] = { ...fields[field], text: held, settled: true };
603
- restored.push(field);
645
+ const current = { ...fields[field], typing: false };
646
+ const fallback = this.fallbackFor(field);
647
+ if (this.final.has(field)) {
648
+ fields[field] = { ...current, settled: true };
649
+ continue;
604
650
  }
605
- else if (unfinished) {
606
- fields[field] = { ...fields[field], settled: true };
651
+ if (current.text === "") {
652
+ fields[field] = { ...restingField(current, fallback), settled: true };
653
+ if (fallback !== "")
654
+ this.final.add(field);
655
+ continue;
607
656
  }
657
+ const unfinished = this.started.has(field) && !this.closed.has(field);
658
+ if (unfinished && fallback.trim() !== "") {
659
+ const decision = this.unfinishedPolicy({ field, typed: current.text, fallback });
660
+ if (decision === "fallback") {
661
+ fields[field] = { ...restingField(current, fallback), settled: true, crossfade: true };
662
+ this.final.add(field);
663
+ continue;
664
+ }
665
+ }
666
+ fields[field] = { ...current, settled: true };
608
667
  }
609
- this.heldCopy = null;
610
- this.demoteUndeliveredProvenance(fields, restored);
611
- this.fallbackOnCanvas.clear();
668
+ return fields;
669
+ }
670
+ /** Terminal, success. */
671
+ done(meta) {
672
+ // A trailing `done` after `error` (or a duplicate `done`) must not re-open
673
+ // a settled stream or rebrand a failure as a success.
674
+ if (this.isTerminal())
675
+ return;
676
+ const fields = this.settleFields();
612
677
  const declared = typeof meta.contentSource === "string" &&
613
678
  CONTENT_SOURCES.includes(meta.contentSource)
614
679
  ? meta.contentSource
615
680
  : null;
681
+ // The stream wrote nothing on the canvas: what is there is the seeded
682
+ // copy, and it keeps its own provenance and variant whatever the stream
683
+ // says it would have served.
684
+ const streamWrote = this.live.size > 0;
685
+ const seededVariant = !streamWrote ? this.options.initialVariantId : undefined;
616
686
  this.state = {
617
687
  ...this.state,
618
688
  phase: "settled",
619
689
  fields,
620
690
  meta,
621
- variantId: typeof meta.variantId === "string" ? meta.variantId : this.state.variantId,
691
+ variantId: seededVariant ??
692
+ (typeof meta.variantId === "string" ? meta.variantId : this.state.variantId),
622
693
  // The action renders as an <a href>; an unprovable scheme stays out of
623
694
  // state and the CTA degrades to a plain button.
624
695
  ctaAction: safeActionUrl(typeof meta.ctaAction === "string" ? meta.ctaAction : undefined) ?? this.state.ctaAction,
625
696
  degraded: typeof meta.degraded === "string" ? meta.degraded : null,
626
- // The server's terminal word on provenance beats the per-field
627
- // observation; it is the value the server itself will stand behind.
628
- contentSource: declared ?? this.state.contentSource,
697
+ // The server's terminal word on provenance — but only for copy that
698
+ // actually reached the canvas. A view that kept its fallback reports
699
+ // static whatever the server generated.
700
+ contentSource: !this.hasAdaptedCopyOnCanvas(fields)
701
+ ? "static"
702
+ : streamWrote
703
+ ? (declared ?? this.state.contentSource)
704
+ : this.state.contentSource,
629
705
  };
630
706
  this.emit();
631
707
  }
632
708
  /**
633
- * The stream failed.
634
- *
635
- * No failure blanks the canvas. A field that closed keeps its delivered
636
- * copy; a field the stream opened but never finished is repainted with its
637
- * WHOLE fallback and demoted to `static`; a still-blank field gets its
638
- * fallback back. The worst honest outcome is that the creative simply
639
- * stopped adapting, which looks like a normal page.
709
+ * Terminal, failure. No failure blanks the canvas: copy that closed stays,
710
+ * a blank field gets its fallback, and a field the stream was still writing
711
+ * goes through the `UnfinishedCopyPolicy`.
640
712
  */
641
713
  failed(message) {
642
- // Terminal is terminal: a late `error` after `done` must not rebrand a
643
- // settled stream as a failure — the copy the reader finished with was
644
- // real and complete.
714
+ // A late `error` after `done` must not rebrand a settled stream.
645
715
  if (this.isTerminal())
646
716
  return;
647
- if (this.state.phase === "holding") {
648
- // Same fork as `done`: copy delivered during the hold paints, so a
649
- // field that closed keeps its delivered text — restoreHeld alone
650
- // repainted the fallback over it. Unfinished fields are repainted with
651
- // the fallback by the loop below either way.
652
- if (COPY_FIELDS.some((field) => (this.pending[field] ?? "") !== "")) {
653
- this.releaseHoldToLive(false);
654
- }
655
- else {
656
- this.restoreHeld();
657
- }
658
- }
659
- this.clearHoldTimer();
660
- const fields = { ...this.state.fields };
661
- const restored = [];
662
- for (const field of COPY_FIELDS) {
663
- fields[field] = { ...fields[field], typing: false };
664
- // Two restores, one rule: a field the stream took but never finished
665
- // ends on its WHOLE fallback.
666
- //
667
- // - A field that STARTED but never reached its `*-done` may be showing
668
- // a half-typed fragment ("Meet the web that") — the original
669
- // stranding bug. Cancel the entrance and repaint the fallback
670
- // wholesale, exactly as the shell's error path repaints the baked
671
- // copy (rendererService.ts streamIsland 'error' → cancelSmoothText +
672
- // hydrateIsland fallback). An earlier revision restored only fields
673
- // still BLANK, so the fragment survived the stream that died
674
- // mid-sentence.
675
- // - A field still blank when the transport dies (the blank the hold
676
- // itself painted) gets its fallback back, as before.
677
- //
678
- // A field that DID close (`*-done`) keeps its delivered text: that copy
679
- // is final and real, and the failure happened elsewhere.
680
- const held = this.heldCopy?.[field] ?? this.initialCopy[field];
681
- const unfinished = this.started.has(field) && !this.closed.has(field);
682
- if ((unfinished || fields[field].text === "") && held !== undefined && held !== "") {
683
- if (fields[field].text !== held)
684
- fields[field] = { ...fields[field], text: held };
685
- restored.push(field);
686
- }
687
- }
688
- this.heldCopy = null;
689
- this.demoteUndeliveredProvenance(fields, restored);
690
- this.fallbackOnCanvas.clear();
691
- // No server word to defer to here, so the stream-level value is recomputed
692
- // from what actually reached the canvas. A failed stream that delivered
693
- // nothing readable leaves a page of authored copy, and the analytics that
694
- // reads `contentSource` should say so rather than reporting an adaptation
695
- // the visitor never saw.
717
+ const fields = this.settleFields();
696
718
  const contentSource = this.hasAdaptedCopyOnCanvas(fields) ? this.state.contentSource : "static";
697
719
  this.state = { ...this.state, phase: "failed", fields, contentSource, error: message };
698
720
  this.emit();
@@ -704,8 +726,7 @@ export class CopyChoreographer {
704
726
  this.listeners.clear();
705
727
  }
706
728
  promoteContentSource(source) {
707
- const rank = { static: 0, cached: 1, generated: 2 };
708
- if (rank[source] > rank[this.state.contentSource]) {
729
+ if (SOURCE_RANK[source] > SOURCE_RANK[this.state.contentSource]) {
709
730
  this.state = { ...this.state, contentSource: source };
710
731
  }
711
732
  }