@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.
- package/README.md +18 -19
- package/dist/analytics.d.ts +20 -2
- package/dist/analytics.d.ts.map +1 -1
- package/dist/analytics.js +95 -7
- package/dist/analytics.js.map +1 -1
- package/dist/client.d.ts +30 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +6 -6
- package/dist/client.js.map +1 -1
- package/dist/context.d.ts +8 -0
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +21 -0
- package/dist/context.js.map +1 -1
- package/dist/fragments.d.ts +34 -27
- package/dist/fragments.d.ts.map +1 -1
- package/dist/fragments.js +15 -8
- package/dist/fragments.js.map +1 -1
- package/dist/index.d.ts +6 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -2
- package/dist/index.js.map +1 -1
- package/dist/protocol.d.ts +12 -1
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +5 -2
- package/dist/protocol.js.map +1 -1
- package/dist/server.d.ts +144 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +463 -0
- package/dist/server.js.map +1 -0
- package/dist/slots.d.ts +66 -0
- package/dist/slots.d.ts.map +1 -0
- package/dist/slots.js +102 -0
- package/dist/slots.js.map +1 -0
- package/dist/state.d.ts +282 -239
- package/dist/state.d.ts.map +1 -1
- package/dist/state.js +434 -413
- package/dist/state.js.map +1 -1
- package/dist/variant.d.ts +70 -1
- package/dist/variant.d.ts.map +1 -1
- package/dist/variant.js +85 -1
- package/dist/variant.js.map +1 -1
- package/dist/visitorContext.d.ts +99 -0
- package/dist/visitorContext.d.ts.map +1 -0
- package/dist/visitorContext.js +84 -0
- package/dist/visitorContext.js.map +1 -0
- package/package.json +5 -1
package/dist/state.js
CHANGED
|
@@ -1,66 +1,112 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Copy choreography — the
|
|
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
|
-
*
|
|
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
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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
|
-
*
|
|
28
|
-
* a budget for the STORED FALLBACK, not for the stream.
|
|
19
|
+
* Every field is in exactly one of three conditions:
|
|
29
20
|
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
* `*-done
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
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
|
-
*
|
|
38
|
-
*
|
|
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
|
|
38
|
+
* How long a copy slot holds blank, waiting for live copy, before the
|
|
39
|
+
* fallback lands.
|
|
45
40
|
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
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
|
|
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
|
-
|
|
74
|
-
|
|
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
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
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
|
-
|
|
135
|
-
|
|
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:
|
|
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
|
-
//
|
|
171
|
-
//
|
|
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
|
|
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
|
|
195
|
-
* visitor
|
|
196
|
-
*
|
|
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
|
-
|
|
204
|
-
if (this.
|
|
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
|
-
|
|
273
|
+
if (this.inView)
|
|
274
|
+
this.armHold();
|
|
216
275
|
this.emit();
|
|
217
276
|
return;
|
|
218
277
|
}
|
|
219
|
-
// Nothing to
|
|
220
|
-
//
|
|
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
|
-
*
|
|
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
|
-
*
|
|
248
|
-
*
|
|
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
|
-
|
|
251
|
-
this.
|
|
291
|
+
slotInView() {
|
|
292
|
+
if (this.inView)
|
|
293
|
+
return;
|
|
294
|
+
this.inView = true;
|
|
252
295
|
if (this.state.phase !== "holding")
|
|
253
296
|
return;
|
|
254
|
-
if (
|
|
255
|
-
this.
|
|
297
|
+
if (this.releaseOn === "text" && this.anyPending()) {
|
|
298
|
+
this.releaseHold("view");
|
|
299
|
+
this.armHold();
|
|
256
300
|
return;
|
|
257
301
|
}
|
|
258
|
-
this.
|
|
302
|
+
this.armHold();
|
|
259
303
|
}
|
|
260
|
-
/**
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
-
*
|
|
282
|
-
*
|
|
283
|
-
*
|
|
284
|
-
*
|
|
285
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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 (
|
|
306
|
-
|
|
307
|
-
|
|
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:
|
|
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
|
-
|
|
330
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
416
|
-
*
|
|
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
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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
|
-
|
|
432
|
-
|
|
476
|
+
if (this.inView && this.releaseOn === "text")
|
|
477
|
+
this.releaseHold("chunk");
|
|
433
478
|
return;
|
|
434
479
|
}
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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.
|
|
444
|
-
*
|
|
445
|
-
*
|
|
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.
|
|
451
|
-
|
|
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.
|
|
500
|
+
if (this.final.has(field)) {
|
|
501
|
+
this.patchField(field, { settled: true, typing: false });
|
|
458
502
|
return;
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
this.
|
|
464
|
-
|
|
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`
|
|
468
|
-
//
|
|
469
|
-
//
|
|
470
|
-
//
|
|
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
|
|
491
|
-
*
|
|
492
|
-
*
|
|
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
|
-
*
|
|
550
|
-
*
|
|
551
|
-
*
|
|
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
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
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
|
-
|
|
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
|
-
|
|
574
|
-
|
|
575
|
-
|
|
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
|
-
|
|
606
|
-
fields[field] = { ...
|
|
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
|
-
|
|
610
|
-
|
|
611
|
-
|
|
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:
|
|
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
|
|
627
|
-
//
|
|
628
|
-
|
|
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
|
-
*
|
|
634
|
-
*
|
|
635
|
-
*
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|