| 401 | + | // bound = inline array → createServerAction(id, bound) with boundArgs.length > 0 |
| 402 | + | const payload = |
| 403 | + | '1:{"id":"bound-id","bound":[1,"s",true,{"x":1}]}\n0:"$h1"\n'; |
| 404 | + | const action = await loadAction(payload); |
| 405 | + | const formAction = action.$$FORM_ACTION("P0"); |
| 406 | + | expect(formAction.name).toBe("$ACTION_REF_P0"); |
| 407 | + | expect(formAction.method).toBe("POST"); |
| 408 | + | expect(formAction.data).toBeInstanceOf(FormData); |
| 409 | + | const payloadStr = formAction.data.get("$ACTION_P0:0"); |
| 410 | + | const parsed = JSON.parse(payloadStr); |
| 411 | + | // Number/bool serialized raw; objects as JSON strings; strings as-is |
| 412 | + | expect(parsed[0]).toBe(1); |
| 413 | + | expect(parsed[1]).toBe("s"); |
| 414 | + | expect(parsed[2]).toBe(true); |
| 415 | + | expect(parsed[3]).toBe('{"x":1}'); |
| 416 | + | }); |
| 417 | + | |
| 418 | + | it("emits a $ACTION_ID_<id> form action when unbound", async () => { |
| 419 | + | const payload = '1:{"id":"plain-id"}\n0:"$h1"\n'; |
| 420 | + | const action = await loadAction(payload); |
| 421 | + | const formAction = action.$$FORM_ACTION("P1"); |
| 422 | + | expect(formAction.name).toBe("$ACTION_ID_plain-id"); |
| 423 | + | expect(formAction.data).toBeNull(); |
| 424 | + | }); |
| 425 | + | |
| 426 | + | it("$$IS_SIGNATURE_EQUAL matches id + bound-arg count", async () => { |
| 427 | + | const payload = '1:{"id":"sig","bound":[1,2]}\n0:"$h1"\n'; |
| 428 | + | const action = await loadAction(payload); |
| 429 | + | expect(action.$$IS_SIGNATURE_EQUAL("sig", 2)).toBe(true); |
| 430 | + | expect(action.$$IS_SIGNATURE_EQUAL("sig", 1)).toBe(false); |
| 431 | + | expect(action.$$IS_SIGNATURE_EQUAL("other", 2)).toBe(false); |
| 432 | + | }); |
| 433 | + | |
| 434 | + | it(".bind() creates a new action with accumulated bound args", async () => { |
| 435 | + | const calls = []; |
| 436 | + | const callServer = (id, args) => { |
| 437 | + | calls.push({ id, args }); |
| 438 | + | return Promise.resolve(); |
| 439 | + | }; |
| 440 | + | const payload = '1:{"id":"b","bound":[1]}\n0:"$h1"\n'; |
| 441 | + | const action = await loadAction(payload, { callServer }); |
| 442 | + | const bound = action.bind(null, 2, 3); |
| 443 | + | expect(bound.$$id).toBe("b"); |
| 444 | + | await bound("arg"); |
| 445 | + | // Original bound (1) + new bound (2,3) + runtime (arg) |
| 446 | + | expect(calls[0]).toEqual({ id: "b", args: [1, 2, 3, "arg"] }); |
| 447 | + | }); |
| 448 | + | }); |
| 449 | + | |
| 450 | + | // ───────────────────────────────────────────────────────────────────────────── |
| 451 | + | // ReadableStream wrapper — cancel() |
| 452 | + | // ───────────────────────────────────────────────────────────────────────────── |
| 453 | + | |
| 454 | + | describe("createStreamWrapper cancel", () => { |
| 455 | + | it("marks the chunk as closed when the consumer cancels the stream", async () => { |
| 456 | + | // $r1 references a streaming ReadableStream chunk. 1:Thello is a |
| 457 | + | // streaming text row (non-length-prefixed — 'h' is not a hex char so |
| 458 | + | // processData falls through to processLine's T-tag → appendTextChunk). |
| 459 | + | const payload = '0:"$r1"\n1:Thello\n'; |
| 460 | + | const stream = await createFromReadableStream(streamOf(payload)); |
| 461 | + | expect(stream).toBeInstanceOf(ReadableStream); |
| 462 | + | const reader = stream.getReader(); |
| 463 | + | const first = await reader.read(); |
| 464 | + | expect(first.value).toBe("hello"); |
| 465 | + | // Cancel → triggers cancel() callback on the wrapper |
| 466 | + | await reader.cancel(); |
| 467 | + | // Further reads complete (stream is cancelled/closed) |
| 468 | + | const next = await reader.read(); |
| 469 | + | expect(next.done).toBe(true); |
| 470 | + | }); |
| 471 | + | }); |
| 472 | + | |
| 473 | + | // ───────────────────────────────────────────────────────────────────────────── |
| 474 | + | // Binary row handling — continueBinaryRow split + processBinaryData new chunk |
| 475 | + | // ───────────────────────────────────────────────────────────────────────────── |
| 476 | + | |
| 477 | + | describe("binary row handling", () => { |
| 478 | + | it("continueBinaryRow concatenates across multiple small stream chunks", async () => { |
| 479 | + | // Length-prefixed T row split across 3 stream deliveries. The hex length |
| 480 | + | // 'a' (10) matches the payload "helloworld". The split forces processData |
| 481 | + | // to build a pendingBinaryRow, continueBinaryRow's "still need more" branch |
| 482 | + | // to run once, then the "have enough" branch to complete the row. |
| 483 | + | const enc = new TextEncoder(); |
| 484 | + | const fullRow = enc.encode("1:Ta,helloworld\n"); |
| 485 | + | // Header "1:Ta," is 5 bytes (indices 0-4), payload 5-14, newline 15 |
| 486 | + | const p1 = fullRow.slice(0, 7); // "1:Ta,he" — triggers pendingBinaryRow |
| 487 | + | const p2 = fullRow.slice(7, 11); // "llow" — still-need-more branch |
| 488 | + | const p3 = fullRow.slice(11); // "orld\n" — have-enough branch |
| 489 | + | const rootRow = enc.encode('0:"$1"\n'); |
| 490 | + | const stream = byteStream(p1, p2, p3, rootRow); |
| 491 | + | const root = await createFromReadableStream(stream); |
| 492 | + | expect(root).toBe("helloworld"); |
| 493 | + | }); |
| 494 | + | |
| 495 | + | it("resolves a length-prefixed Uint8Array row to a Uint8Array value", async () => { |
| 496 | + | // Tag 'o' (0x6f) → Uint8Array. Length in hex: 3 bytes of binary [1,2,3]. |
| 497 | + | const enc = new TextEncoder(); |
| 498 | + | const header = enc.encode("1:o3,"); |
| 499 | + | const payload = new Uint8Array([1, 2, 3]); |
| 500 | + | const nl = enc.encode('\n0:"$1"\n'); |
| 501 | + | const combined = new Uint8Array(header.length + payload.length + nl.length); |
| 502 | + | combined.set(header, 0); |
| 503 | + | combined.set(payload, header.length); |
| 504 | + | combined.set(nl, header.length + payload.length); |
| 505 | + | const root = await createFromReadableStream(byteStream(combined)); |
| 506 | + | expect(root).toBeInstanceOf(Uint8Array); |
| 507 | + | expect(Array.from(root)).toEqual([1, 2, 3]); |
| 508 | + | }); |
| 509 | + | }); |
| 510 | + | |
| 511 | + | // ───────────────────────────────────────────────────────────────────────────── |
| 512 | + | // Public createServerReference — .bind() chain |
| 513 | + | // ───────────────────────────────────────────────────────────────────────────── |
| 514 | + | |
| 515 | + | describe("createServerReference public API", () => { |
| 516 | + | it("returns an async action with server-reference metadata", async () => { |
| 517 | + | const callServer = vi.fn(async (id, args) => ({ id, args })); |
| 518 | + | const action = createServerReference("my-ref", callServer); |
| 519 | + | expect(action.$$typeof).toBe(REACT_SERVER_REFERENCE); |
| 520 | + | expect(action.$$id).toBe("my-ref"); |
| 521 | + | expect(action.$$bound).toBeNull(); |
| 522 | + | const result = await action(1, 2); |
| 523 | + | expect(callServer).toHaveBeenCalledWith("my-ref", [1, 2]); |
| 524 | + | expect(result).toEqual({ id: "my-ref", args: [1, 2] }); |
| 525 | + | }); |
| 526 | + | |
| 527 | + | it(".bind() chains accumulate bound arguments across binds", async () => { |
| 528 | + | const calls = []; |
| 529 | + | const action = createServerReference("chained", (id, args) => { |
| 530 | + | calls.push({ id, args }); |
| 531 | + | return Promise.resolve(); |
| 532 | + | }); |
| 533 | + | const once = action.bind(null, "a"); |
| 534 | + | const twice = once.bind(null, "b", "c"); |
| 535 | + | expect(once.$$bound).toEqual(["a"]); |
| 536 | + | expect(twice.$$bound).toEqual(["a", "b", "c"]); |
| 537 | + | await twice("runtime"); |
| 538 | + | expect(calls[0]).toEqual({ |
| 539 | + | id: "chained", |
| 540 | + | args: ["a", "b", "c", "runtime"], |
| 541 | + | }); |
| 542 | + | }); |
| 543 | + | }); |
| 544 | + | |
| 545 | + | // ───────────────────────────────────────────────────────────────────────────── |
| 546 | + | // appendFilesToFormData — walk into $$bound on server references |
| 547 | + | // ───────────────────────────────────────────────────────────────────────────── |
| 548 | + | |
| 549 | + | describe("appendFilesToFormData via encodeReply", () => { |
| 550 | + | it("traverses $$bound on a server reference to find Files", async () => { |
| 551 | + | // Create a server reference that has a File in its bound args. Pass that |
| 552 | + | // reference as a top-level value to encodeReply. The resulting FormData |
| 553 | + | // should contain the File under a bound-scoped key. |
| 554 | + | const action = createServerReference("ref", async () => {}); |
| 555 | + | const file = new File(["hi"], "hello.txt", { type: "text/plain" }); |
| 556 | + | const boundAction = action.bind(null, file); |
| 557 | + | |
| 558 | + | const result = await encodeReply({ action: boundAction }); |
| 559 | + | // hasFileOrBlob saw the File inside $$bound and FormData branch runs |
| 560 | + | expect(result).toBeInstanceOf(FormData); |
| 561 | + | // Some FormData entry holds the original File |
| 562 | + | let foundFile = null; |
| 563 | + | for (const [, v] of result.entries()) { |
| 564 | + | if (v instanceof File && v.name === "hello.txt") { |
| 565 | + | foundFile = v; |
| 566 | + | break; |
| 567 | + | } |
| 568 | + | } |
| 569 | + | expect(foundFile).not.toBeNull(); |
| 570 | + | }); |
| 571 | + | }); |
| 572 | + | |
| 573 | + | // ───────────────────────────────────────────────────────────────────────────── |
| 574 | + | // $Y custom TypedArray via typeRegistry |
| 575 | + | // ───────────────────────────────────────────────────────────────────────────── |
| 576 | + | |
| 577 | + | describe("$Y typed array typeRegistry lookup", () => { |
| 578 | + | it("uses typeRegistry for custom typed-array classes", async () => { |
| 579 | + | class MyView { |
| 580 | + | constructor(buffer) { |
| 581 | + | this.buffer = buffer; |
| 582 | + | this.tag = "custom"; |
| 583 | + | } |
| 584 | + | // Non-constructor member so oxlint doesn't flag this as constructor-only |
| 585 | + | byteLength() { |
| 586 | + | return this.buffer ? this.buffer.byteLength : 0; |
| 587 | + | } |
| 588 | + | } |
| 589 | + | // $Y row encodes {type, data(base64)}. Base64 of [1,2,3] = "AQID" |
| 590 | + | const payload = '0:"$Y{\\"type\\":\\"MyView\\",\\"data\\":\\"AQID\\"}"\n'; |
| 591 | + | const root = await createFromReadableStream(streamOf(payload), { |
| 592 | + | typeRegistry: { MyView }, |
| 593 | + | }); |
| 594 | + | expect(root).toBeInstanceOf(MyView); |
| 595 | + | expect(root.tag).toBe("custom"); |
| 596 | + | }); |
| 597 | + | }); |