render: support streamed framework hydration

Make attribute maps live, preserve parser script execution state, and expose
element scrolling used by React commit callbacks. Connect fallback web streams
so React Router and Remix hydration payloads flow through codec transforms.
This commit is contained in:
SGavrl committed 2026-07-23 10:59:51 +00:00
1 parent 571c419e51
commit 5de5d07c24
3 files changed
+472 -57

No files matched your search

+16
View File
@@ -679,6 +679,22 @@ impl Page {
None => return, None => return,
}; };
// HTML scripts have an "already started" flag. Mark every
// parser-discovered script before running page code so React/Next
// hydration can move or hoist those nodes without appendChild
// executing them a second time.
if let Some(js) = &mut self.js {
let ids = all_scripts
.iter()
.map(|script| script.nid.to_string())
.collect::<Vec<_>>()
.join(",");
let _ = js.execute_script(
"<parser-scripts>",
&format!("globalThis.__markParserScripts([{}]);", ids),
);
}
let mut regular = Vec::new(); let mut regular = Vec::new();
let mut deferred = Vec::new(); let mut deferred = Vec::new();
let mut async_scripts = Vec::new(); let mut async_scripts = Vec::new();
+312 -57
View File
@@ -16,7 +16,7 @@
'__obscura_objects', '__obscura_oid', '__obscura_ua', '__obscura_objects', '__obscura_oid', '__obscura_ua',
'__obscura_platform', '__obscura_ua_platform', '__obscura_ua_platform_version', '__obscura_platform', '__obscura_ua_platform', '__obscura_ua_platform_version',
'__obscura_stealth', '__obscura_markTrusted', '__obscura_stealth', '__obscura_markTrusted',
'__obscura_hw', '__obscura_mem', '__obscura_hw', '__obscura_mem', '__markParserScripts',
'__documentReadyState__', '__currentUrl', '__documentReadyState__', '__currentUrl',
// internal helpers (var-declared throughout the file) // internal helpers (var-declared throughout the file)
'__processDynScriptQueue', '_decodeDataScriptUrl', '_markNative', '_fpRand', '_fpNoise', '__processDynScriptQueue', '_decodeDataScriptUrl', '_markNative', '_fpRand', '_fpNoise',
@@ -45,7 +45,7 @@
// the later `globalThis.X = X` assignments only update the value. // the later `globalThis.X = X` assignments only update the value.
'Node', 'Element', 'Document', 'DocumentFragment', 'DocumentType', 'Node', 'Element', 'Document', 'DocumentFragment', 'DocumentType',
'Text', 'Comment', 'CDATASection', 'ProcessingInstruction', 'CharacterData', 'Text', 'Comment', 'CDATASection', 'ProcessingInstruction', 'CharacterData',
'CSSStyleDeclaration', 'DOMTokenList', 'Screen', 'NetworkInformation', 'CSSStyleDeclaration', 'DOMTokenList', 'NamedNodeMap', 'Screen', 'NetworkInformation',
'MessageChannel', 'MessagePort', 'CustomElementRegistry', 'MessageChannel', 'MessagePort', 'CustomElementRegistry',
'XMLHttpRequestEventTarget', 'HTMLMediaElement', 'HTMLVideoElement', 'XMLHttpRequestEventTarget', 'HTMLMediaElement', 'HTMLVideoElement',
'HTMLAudioElement', 'WebGL2RenderingContext', 'HTMLAudioElement', 'WebGL2RenderingContext',
@@ -227,6 +227,14 @@ function _decodeDataScriptUrl(url) {
} }
return new TextDecoder().decode(new Uint8Array(bytes)); return new TextDecoder().decode(new Uint8Array(bytes));
} }
// A script element executes at most once. Parser-discovered scripts are marked
// before page execution starts, and dynamically inserted scripts are marked
// when they are queued. Framework hydration may move existing <script> nodes;
// moving one must not execute its source or inline body a second time.
let __startedScriptNids = new Set();
globalThis.__markParserScripts = function(nids) {
for (const nid of nids || []) __startedScriptNids.add(+nid);
};
async function __processDynScriptQueue() { async function __processDynScriptQueue() {
if (__dynScriptBusy) return; if (__dynScriptBusy) return;
__dynScriptBusy = true; __dynScriptBusy = true;
@@ -832,12 +840,16 @@ class Node {
_dom("append_child", this._nid, c._nid); _dom("append_child", this._nid, c._nid);
if (globalThis.__mutationObservers?.length) globalThis.__notifyMutation('childList', this._nid, [c._nid], []); if (globalThis.__mutationObservers?.length) globalThis.__notifyMutation('childList', this._nid, [c._nid], []);
if (c instanceof Element && c.tagName === 'SCRIPT') { if (c instanceof Element && c.tagName === 'SCRIPT') {
if (__startedScriptNids.has(c._nid)) return c;
const scriptType = c.getAttribute('type') || ''; const scriptType = c.getAttribute('type') || '';
const isModule = scriptType === 'module'; const isModule = scriptType === 'module';
if (scriptType && !isModule && scriptType !== 'text/javascript' && scriptType !== 'application/javascript') { if (scriptType && !isModule && scriptType !== 'text/javascript' && scriptType !== 'application/javascript') {
return c; return c;
} }
const src = c.getAttribute('src'); const src = c.getAttribute('src');
const code = src ? "" : c.textContent;
if (!src && !code) return c;
__startedScriptNids.add(c._nid);
const prevNid = globalThis.__currentScriptNid; const prevNid = globalThis.__currentScriptNid;
if (src) { if (src) {
// Resolve against <base href> when present, else the document URL. // Resolve against <base href> when present, else the document URL.
@@ -876,8 +888,6 @@ class Node {
}); });
__processDynScriptQueue(); __processDynScriptQueue();
} else { } else {
const code = c.textContent;
if (code) {
if (isModule) { if (isModule) {
const dataUrl = 'data:text/javascript;base64,' + btoa(unescape(encodeURIComponent(code))); const dataUrl = 'data:text/javascript;base64,' + btoa(unescape(encodeURIComponent(code)));
__dynScriptQueue.push({ __dynScriptQueue.push({
@@ -897,7 +907,6 @@ class Node {
} }
} }
} }
}
if (c instanceof Element && c.tagName === 'LINK') { if (c instanceof Element && c.tagName === 'LINK') {
_loadLinkedStylesheet(c); _loadLinkedStylesheet(c);
} }
@@ -1361,6 +1370,79 @@ function _parseHTMLFragment(html, context) {
return out; return out;
} }
class NamedNodeMap {
constructor(element) {
Object.defineProperty(this, "_element", {
value: element,
configurable: false,
enumerable: false,
writable: false,
});
return new Proxy(this, {
get(target, prop, receiver) {
if (typeof prop === "string" && /^(?:0|[1-9]\d*)$/.test(prop)) {
return target.item(+prop);
}
if (Reflect.has(target, prop)) return Reflect.get(target, prop, receiver);
if (typeof prop === "string") return target.getNamedItem(prop);
return undefined;
},
ownKeys(target) {
const names = target._names();
return Reflect.ownKeys(target).concat(
names.map((_, i) => String(i)),
names.filter((name) => !Reflect.has(target, name))
);
},
getOwnPropertyDescriptor(target, prop) {
if (typeof prop === "string" && (/^(?:0|[1-9]\d*)$/.test(prop) || target._names().includes(prop))) {
return { configurable: true, enumerable: true, value: target[prop], writable: false };
}
return Reflect.getOwnPropertyDescriptor(target, prop);
},
});
}
_names() {
return _domParse("attribute_names", this._element._nid) || [];
}
_attr(name) {
const value = this._element.getAttribute(name);
if (value === null) return null;
const attr = new Attr(name, value, null, null);
attr.ownerElement = this._element;
return attr;
}
get length() { return this._names().length; }
item(index) {
const name = this._names()[Number(index)];
return name === undefined ? null : this._attr(name);
}
getNamedItem(name) {
name = String(name);
return this._names().includes(name) ? this._attr(name) : null;
}
getNamedItemNS(namespaceURI, localName) {
return this.getNamedItem(localName);
}
setNamedItem(attr) {
if (!attr || typeof attr.name !== "string") return null;
return this._element.setAttributeNode(attr);
}
setNamedItemNS(attr) { return this.setNamedItem(attr); }
removeNamedItem(name) {
const attr = this.getNamedItem(name);
if (!attr) throw new DOMException("Attribute not found", "NotFoundError");
return this._element.removeAttributeNode(attr);
}
removeNamedItemNS(namespaceURI, localName) {
return this.removeNamedItem(localName);
}
*[Symbol.iterator]() {
for (let i = 0; i < this.length; i++) yield this.item(i);
}
}
globalThis.NamedNodeMap = NamedNodeMap;
class Element extends Node { class Element extends Node {
constructor(nid) { constructor(nid) {
super(nid); super(nid);
@@ -1554,35 +1636,11 @@ class Element extends Node {
if (ns === "" && n === "style") this._style._replaceFromAttribute(""); if (ns === "" && n === "style") this._style._replaceFromAttribute("");
} }
hasAttribute(n) { return this.getAttribute(n) !== null; } hasAttribute(n) { return this.getAttribute(n) !== null; }
hasAttributes() { return true; } // Simplified hasAttributes() { return this.attributes.length > 0; }
getAttributeNames() { return _domParse("attribute_names", this._nid) || []; } getAttributeNames() { return _domParse("attribute_names", this._nid) || []; }
get attributes() { get attributes() {
const el = this; if (!this._attributes) this._attributes = new NamedNodeMap(this);
const names = _domParse("attribute_names", el._nid) || []; return this._attributes;
const list = names.map((name) => {
const v = el.getAttribute(name) ?? "";
return {
name,
localName: name,
value: v,
namespaceURI: null,
prefix: null,
specified: true,
ownerElement: el,
nodeName: name,
nodeValue: v,
nodeType: 2,
};
});
list.length = names.length;
list.getNamedItem = (n) => names.includes(n) ? list[names.indexOf(n)] : null;
list.setNamedItem = (a) => { if (a && a.name) el.setAttribute(a.name, a.value); return a; };
list.removeNamedItem = (n) => { const a = list.getNamedItem(n); if (a) el.removeAttribute(n); return a; };
list.item = (i) => list[i] || null;
for (let i = 0; i < names.length; i++) {
Object.defineProperty(list, names[i], { value: list[i], configurable: true, enumerable: false });
}
return list;
} }
getAttributeNS(ns, n) { return _domParse("get_attribute_ns", this._nid, String(ns == null ? "" : ns) + "\0" + String(n)); } getAttributeNS(ns, n) { return _domParse("get_attribute_ns", this._nid, String(ns == null ? "" : ns) + "\0" + String(n)); }
querySelector(s) { return _wrapEl(+_dom("query_selector_scoped", this._nid, s)); } querySelector(s) { return _wrapEl(+_dom("query_selector_scoped", this._nid, s)); }
@@ -3508,17 +3566,36 @@ _markNative(MimeTypeArray.prototype.item);
_markNative(MimeTypeArray.prototype.namedItem); _markNative(MimeTypeArray.prototype.namedItem);
class NetworkInformation { class NetworkInformation {
constructor() { _makeListenerBox(this); } constructor() { this._listeners = Object.create(null); }
get downlink() { return 10; } get downlink() { return 10; }
get downlinkMax() { return Infinity; } get downlinkMax() { return Infinity; }
get effectiveType() { return '4g'; } get effectiveType() { return '4g'; }
get rtt() { return 50; } get rtt() { return 50; }
get saveData() { return false; } get saveData() { return false; }
get type() { return 'wifi'; } get type() { return 'wifi'; }
get onchange() { return null; } get onchange() { return this._onchange || null; }
set onchange(v) {} set onchange(v) { this._onchange = typeof v === "function" ? v : null; }
get ontypechange() { return null; } get ontypechange() { return this._ontypechange || null; }
set ontypechange(v) {} set ontypechange(v) { this._ontypechange = typeof v === "function" ? v : null; }
addEventListener(type, listener) {
if (typeof listener !== "function") return;
(this._listeners[type] || (this._listeners[type] = [])).push(listener);
}
removeEventListener(type, listener) {
const listeners = this._listeners[type];
if (listeners) this._listeners[type] = listeners.filter((item) => item !== listener);
}
dispatchEvent(event) {
if (!event || !event.type) return true;
for (const listener of this._listeners[event.type] || []) {
try { listener.call(this, event); } catch (error) { console.error(error); }
}
const handler = this["on" + event.type];
if (typeof handler === "function") {
try { handler.call(this, event); } catch (error) { console.error(error); }
}
return !event.defaultPrevented;
}
} }
_markNative(NetworkInformation); _markNative(NetworkInformation);
globalThis.NetworkInformation = NetworkInformation; globalThis.NetworkInformation = NetworkInformation;
@@ -7696,28 +7773,124 @@ globalThis.cancelIdleCallback = globalThis.cancelIdleCallback || function(id) {
if (typeof ReadableStream === 'undefined') { if (typeof ReadableStream === 'undefined') {
globalThis.ReadableStream = class ReadableStream { globalThis.ReadableStream = class ReadableStream {
constructor(source = {}, strategy = {}) { constructor(source = {}, strategy = {}) {
this._source = source; this._queue = []; this._closed = false; this._source = source;
this._queue = [];
this._reads = [];
this._state = "readable";
this._error = null;
this.locked = false; this.locked = false;
if (source.start) source.start({ enqueue: (chunk) => this._queue.push(chunk), close: () => { this._closed = true; }, error: () => {} }); const stream = this;
this._controller = {
enqueue(chunk) {
if (stream._state !== "readable") return;
const pending = stream._reads.shift();
if (pending) pending.resolve({value: chunk, done: false});
else stream._queue.push(chunk);
},
close() {
if (stream._state !== "readable") return;
stream._state = "closed";
while (stream._reads.length) {
stream._reads.shift().resolve({value: undefined, done: true});
}
},
error(error) {
if (stream._state !== "readable") return;
stream._state = "errored";
stream._error = error;
while (stream._reads.length) stream._reads.shift().reject(error);
},
get desiredSize() { return Math.max(0, 1 - stream._queue.length); },
};
try {
const started = source.start?.(this._controller);
if (started && typeof started.then === "function") {
started.catch((error) => this._controller.error(error));
}
} catch (error) {
this._controller.error(error);
}
} }
getReader() { getReader() {
if (this.locked) throw new TypeError("ReadableStream is locked");
this.locked = true; this.locked = true;
const stream = this; const stream = this;
return { return {
read() { read() {
if (stream._queue.length > 0) return Promise.resolve({ value: stream._queue.shift(), done: false }); if (stream._queue.length > 0) return Promise.resolve({ value: stream._queue.shift(), done: false });
if (stream._closed) return Promise.resolve({ value: undefined, done: true }); if (stream._state === "closed") return Promise.resolve({ value: undefined, done: true });
return Promise.resolve({ value: undefined, done: true }); if (stream._state === "errored") return Promise.reject(stream._error);
return new Promise((resolve, reject) => stream._reads.push({resolve, reject}));
}, },
releaseLock() { stream.locked = false; }, releaseLock() { stream.locked = false; },
cancel() { stream._closed = true; return Promise.resolve(); }, cancel(reason) { return stream.cancel(reason); },
get closed() { return stream._closed ? Promise.resolve() : new Promise(() => {}); }, get closed() {
if (stream._state === "closed") return Promise.resolve();
if (stream._state === "errored") return Promise.reject(stream._error);
return new Promise((resolve, reject) => {
const poll = () => {
if (stream._state === "closed") resolve();
else if (stream._state === "errored") reject(stream._error);
else setTimeout(poll, 0);
};
poll();
});
},
}; };
} }
cancel() { this._closed = true; return Promise.resolve(); } cancel(reason) {
pipeTo(dest) { return Promise.resolve(); } this._queue.length = 0;
pipeThrough(transform) { return transform.readable || new ReadableStream(); } this._controller.close();
tee() { return [new ReadableStream(), new ReadableStream()]; } try { return Promise.resolve(this._source.cancel?.(reason)); }
catch (error) { return Promise.reject(error); }
}
async pipeTo(destination) {
const reader = this.getReader();
const writer = destination.getWriter();
try {
while (true) {
const {value, done} = await reader.read();
if (done) break;
await writer.write(value);
}
await writer.close();
} catch (error) {
try { await writer.abort(error); } catch {}
throw error;
} finally {
reader.releaseLock();
writer.releaseLock();
}
}
pipeThrough(transform) {
this.pipeTo(transform.writable).catch((error) => {
try { transform.readable._controller?.error(error); } catch {}
});
return transform.readable;
}
tee() {
let leftController;
let rightController;
const left = new ReadableStream({start(controller) { leftController = controller; }});
const right = new ReadableStream({start(controller) { rightController = controller; }});
(async () => {
try {
const reader = this.getReader();
while (true) {
const {value, done} = await reader.read();
if (done) break;
leftController.enqueue(value);
rightController.enqueue(value);
}
leftController.close();
rightController.close();
} catch (error) {
leftController.error(error);
rightController.error(error);
}
})();
return [left, right];
}
[Symbol.asyncIterator]() { [Symbol.asyncIterator]() {
const reader = this.getReader(); const reader = this.getReader();
return { next: () => reader.read(), return: () => { reader.releaseLock(); return Promise.resolve({done:true}); } }; return { next: () => reader.read(), return: () => { reader.releaseLock(); return Promise.resolve({done:true}); } };
@@ -7726,32 +7899,113 @@ if (typeof ReadableStream === 'undefined') {
} }
if (typeof WritableStream === 'undefined') { if (typeof WritableStream === 'undefined') {
globalThis.WritableStream = class WritableStream { globalThis.WritableStream = class WritableStream {
constructor(sink = {}) { this._sink = sink; this.locked = false; } constructor(sink = {}) {
this._sink = sink;
this._state = "writable";
this._error = null;
this._chain = Promise.resolve();
this.locked = false;
try {
const started = sink.start?.({});
if (started && typeof started.then === "function") this._chain = Promise.resolve(started);
} catch (error) {
this._state = "errored";
this._error = error;
this._chain = Promise.reject(error);
}
}
getWriter() { getWriter() {
if (this.locked) throw new TypeError("WritableStream is locked");
this.locked = true; this.locked = true;
const stream = this; const stream = this;
return { return {
write(chunk) { if (stream._sink.write) stream._sink.write(chunk); return Promise.resolve(); }, write(chunk) {
close() { if (stream._sink.close) stream._sink.close(); return Promise.resolve(); }, if (stream._state !== "writable") return Promise.reject(stream._error || new TypeError("WritableStream is closed"));
abort() { return Promise.resolve(); }, stream._chain = stream._chain.then(() => stream._sink.write?.(chunk));
return stream._chain;
},
close() {
if (stream._state !== "writable") return stream._chain;
stream._state = "closed";
stream._chain = stream._chain.then(() => stream._sink.close?.());
return stream._chain;
},
abort(reason) {
stream._state = "errored";
stream._error = reason;
stream._chain = stream._chain.then(() => stream._sink.abort?.(reason));
return stream._chain;
},
releaseLock() { stream.locked = false; }, releaseLock() { stream.locked = false; },
get ready() { return Promise.resolve(); }, get ready() { return stream._chain.then(() => undefined); },
get closed() { return Promise.resolve(); }, get closed() { return stream._chain.then(() => undefined); },
get desiredSize() { return 1; }, get desiredSize() { return 1; },
}; };
} }
close() { return Promise.resolve(); } close() { const writer = this.getWriter(); return writer.close().finally(() => writer.releaseLock()); }
abort() { return Promise.resolve(); } abort(reason) { const writer = this.getWriter(); return writer.abort(reason).finally(() => writer.releaseLock()); }
}; };
} }
if (typeof TransformStream === 'undefined') { if (typeof TransformStream === 'undefined') {
globalThis.TransformStream = class TransformStream { globalThis.TransformStream = class TransformStream {
constructor(transformer = {}) { constructor(transformer = {}) {
this.readable = new ReadableStream(); let controller;
this.writable = new WritableStream(); this.readable = new ReadableStream({
start(readableController) { controller = readableController; },
});
this.writable = new WritableStream({
async write(chunk) {
if (transformer.transform) await transformer.transform(chunk, controller);
else controller.enqueue(chunk);
},
async close() {
if (transformer.flush) await transformer.flush(controller);
controller.close();
},
abort(reason) { controller.error(reason); },
});
try { transformer.start?.(controller); }
catch (error) { controller.error(error); }
} }
}; };
} }
if (typeof TextEncoderStream === 'undefined') {
globalThis.TextEncoderStream = class TextEncoderStream {
constructor() {
const encoder = new TextEncoder();
const transform = new TransformStream({
transform(chunk, controller) {
controller.enqueue(encoder.encode(String(chunk)));
},
});
this.readable = transform.readable;
this.writable = transform.writable;
}
get encoding() { return "utf-8"; }
};
}
if (typeof TextDecoderStream === 'undefined') {
globalThis.TextDecoderStream = class TextDecoderStream {
constructor(label = "utf-8", options = {}) {
const decoder = new TextDecoder(label, options);
const transform = new TransformStream({
transform(chunk, controller) {
controller.enqueue(decoder.decode(chunk, {stream: true}));
},
flush(controller) {
const tail = decoder.decode();
if (tail) controller.enqueue(tail);
},
});
this.readable = transform.readable;
this.writable = transform.writable;
this._decoder = decoder;
}
get encoding() { return this._decoder.encoding; }
get fatal() { return this._decoder.fatal; }
get ignoreBOM() { return this._decoder.ignoreBOM; }
};
}
if (!globalThis.crypto) globalThis.crypto = {}; if (!globalThis.crypto) globalThis.crypto = {};
if (!globalThis.crypto.subtle) { if (!globalThis.crypto.subtle) {
@@ -8487,6 +8741,7 @@ if (typeof ShadowRoot !== 'undefined' && !ShadowRoot.prototype.elementFromPoint)
globalThis.__obscura_init = function() { globalThis.__obscura_init = function() {
_fpSeed = Date.now() ^ (Math.random() * 0xFFFFFFFF >>> 0); _fpSeed = Date.now() ^ (Math.random() * 0xFFFFFFFF >>> 0);
_fpCache = null; _fpCache = null;
__startedScriptNids.clear();
// A real navigation just completed (this runs after set_url), so drop any // A real navigation just completed (this runs after set_url), so drop any
// URL a location setter previewed synchronously and let document_url drive // URL a location setter previewed synchronously and let document_url drive
// location.href again, including any redirect target. // location.href again, including any redirect target.
+144
View File
@@ -1782,6 +1782,78 @@ mod tests {
assert_eq!(tag, serde_json::json!("DIV")); assert_eq!(tag, serde_json::json!("DIV"));
} }
#[test]
fn attributes_named_node_map_is_live() {
let mut rt = setup_runtime(r#"<div id="test" class="card" data-state="ready"></div>"#);
let result = rt
.evaluate(
r#"
const element = document.getElementById("test");
const attributes = element.attributes;
const sameObject = attributes === element.attributes;
const firstName = attributes[0].name;
let removed = 0;
while (attributes.length) {
element.removeAttributeNode(attributes[0]);
removed++;
if (removed > 10) throw new Error("NamedNodeMap is not live");
}
return {
sameObject,
namedNodeMap: attributes instanceof NamedNodeMap,
firstName,
removed,
length: attributes.length,
hasAttributes: element.hasAttributes(),
};
"#,
)
.unwrap();
assert_eq!(
result,
serde_json::json!({
"sameObject": true,
"namedNodeMap": true,
"firstName": "id",
"removed": 3,
"length": 0,
"hasAttributes": false,
})
);
}
#[test]
fn element_scroll_methods_update_scroll_offsets() {
let mut rt = setup_runtime(r#"<div id="scroller"></div>"#);
let result = rt
.evaluate(
r#"
const element = document.getElementById("scroller");
element.scrollTo({left: 12, top: 20, behavior: "smooth"});
element.scrollBy(3, -5);
element.scroll({left: 7});
return {
left: element.scrollLeft,
top: element.scrollTop,
methods: [
typeof element.scroll,
typeof element.scrollTo,
typeof element.scrollBy,
],
};
"#,
)
.unwrap();
assert_eq!(
result,
serde_json::json!({
"left": 7,
"top": 15,
"methods": ["function", "function", "function"],
})
);
}
#[test] #[test]
fn document_fragment_get_element_by_id_searches_descendants() { fn document_fragment_get_element_by_id_searches_descendants() {
let mut rt = setup_runtime(r#"<div id="target">document</div>"#); let mut rt = setup_runtime(r#"<div id="target">document</div>"#);
@@ -4002,6 +4074,78 @@ mod tests {
); );
} }
#[test]
fn text_codec_streams_expose_browser_shape() {
let mut rt = setup_runtime("<div></div>");
let result = rt
.evaluate(
r#"
const encoder = new TextEncoderStream();
const decoder = new TextDecoderStream();
return {
encoder: encoder.encoding,
encoderReadable: typeof encoder.readable.getReader,
encoderWritable: typeof encoder.writable.getWriter,
decoder: decoder.encoding,
decoderReadable: typeof decoder.readable.getReader,
decoderWritable: typeof decoder.writable.getWriter,
};
"#,
)
.unwrap();
assert_eq!(
result,
serde_json::json!({
"encoder": "utf-8",
"encoderReadable": "function",
"encoderWritable": "function",
"decoder": "utf-8",
"decoderReadable": "function",
"decoderWritable": "function",
})
);
}
#[tokio::test(flavor = "current_thread")]
async fn text_encoder_stream_pipe_through_delivers_hydration_data() {
let mut rt = setup_runtime("<div></div>");
let result = rt
.evaluate_for_cdp(
r#"
(async () => {
let sourceController;
const source = new ReadableStream({
start(controller) { sourceController = controller; },
});
const encoded = source.pipeThrough(new TextEncoderStream());
sourceController.enqueue('["server",{"hydrated":true}]\n');
sourceController.close();
const decoder = new TextDecoder();
let tail = "";
const lines = encoded.pipeThrough(new TransformStream({
transform(chunk, controller) {
const complete = (tail + decoder.decode(chunk, {stream: true})).split("\n");
tail = complete.pop() || "";
for (const line of complete) controller.enqueue(line);
},
flush(controller) { if (tail) controller.enqueue(tail); },
}));
const first = await lines.getReader().read();
return JSON.parse(first.value);
})()
"#,
true,
true,
)
.await
.unwrap();
assert_eq!(
result.value.unwrap(),
serde_json::json!(["server", {"hydrated": true}])
);
}
/// Regression test for #285: DDoS-Guard's challenge calls /// Regression test for #285: DDoS-Guard's challenge calls
/// `t.insertAdjacentText(...)` and dies with `TypeError: ... is not a /// `t.insertAdjacentText(...)` and dies with `TypeError: ... is not a
/// function` because `Element.prototype.insertAdjacentText` was missing. /// function` because `Element.prototype.insertAdjacentText` was missing.