|
| 1 | +/** |
| 2 | + * @jsxImportSource @solidjs/web |
| 3 | + * |
| 4 | + * Concurrent renders × each render's `serialize`. A finished `renderToString` |
| 5 | + * context lingers as the module-global `sharedConfig.context`, so a write |
| 6 | + * that lands later for another in-flight render must be judged by the render |
| 7 | + * it belongs to — never by whatever context the global happens to hold. |
| 8 | + */ |
| 9 | +import { describe, expect, test } from "vitest"; |
| 10 | +import { renderToString, renderToStream, Loading } from "@solidjs/web"; |
| 11 | +import { createMemo, getOwner } from "solid-js"; |
| 12 | +import { sharedConfig } from "solid-js/internal"; |
| 13 | +import { hydrationRecordKeys } from "../harness/hydration-records.js"; |
| 14 | + |
| 15 | +function delay(ms: number) { |
| 16 | + return new Promise(r => setTimeout(r, ms)); |
| 17 | +} |
| 18 | + |
| 19 | +function renderComplete(code: () => any, options: any = {}): Promise<string> { |
| 20 | + return new Promise(resolve => { |
| 21 | + renderToStream(code, options).then(resolve); |
| 22 | + }); |
| 23 | +} |
| 24 | + |
| 25 | +function otherRequestCompletes(streamContext: unknown) { |
| 26 | + renderToString(() => <p>other request</p>); |
| 27 | + expect(sharedConfig.context).not.toBe(streamContext); |
| 28 | +} |
| 29 | + |
| 30 | +describe("a stream's writes after another request's renderToString finished", () => { |
| 31 | + for (const interleave of [false, true]) { |
| 32 | + test(`library write from IO after the render pass (interleaved renderToString: ${interleave})`, async () => { |
| 33 | + let lateWrite!: () => void; |
| 34 | + const gate = new Promise<void>(r => (lateWrite = r)); |
| 35 | + const hold = delay(30); |
| 36 | + let streamContext: unknown; |
| 37 | + const streamed = renderComplete(() => { |
| 38 | + // Captured like solid-query's provider: written later from a cache event. |
| 39 | + const ctx = (streamContext = sharedConfig.context) as any; |
| 40 | + gate.then(() => ctx.serialize("lib:late", { v: "late" })); |
| 41 | + function Held() { |
| 42 | + const m = createMemo(async () => { |
| 43 | + await hold; |
| 44 | + return "held"; |
| 45 | + }); |
| 46 | + return <b>{m()}</b>; |
| 47 | + } |
| 48 | + return ( |
| 49 | + <div> |
| 50 | + <Loading fallback={<i>...</i>}> |
| 51 | + <Held /> |
| 52 | + </Loading> |
| 53 | + </div> |
| 54 | + ); |
| 55 | + }); |
| 56 | + if (interleave) otherRequestCompletes(streamContext); |
| 57 | + lateWrite(); |
| 58 | + expect(hydrationRecordKeys(await streamed)).toContain("lib:late"); |
| 59 | + }); |
| 60 | + |
| 61 | + test(`runtime write: chained async memo on a Loading retry pass (interleaved renderToString: ${interleave})`, async () => { |
| 62 | + const hold = delay(20); |
| 63 | + let bOwner: string | undefined; |
| 64 | + let streamContext: unknown; |
| 65 | + const streamed = renderComplete(() => { |
| 66 | + streamContext = sharedConfig.context; |
| 67 | + function Chained() { |
| 68 | + const a = createMemo(async () => { |
| 69 | + await hold; |
| 70 | + return 1; |
| 71 | + }); |
| 72 | + return (() => { |
| 73 | + const v = a(); |
| 74 | + const b = createMemo(async () => { |
| 75 | + await delay(5); |
| 76 | + return "b" + v; |
| 77 | + }); |
| 78 | + bOwner = getOwner()?.id; |
| 79 | + return <b>{b()}</b>; |
| 80 | + })(); |
| 81 | + } |
| 82 | + return ( |
| 83 | + <div> |
| 84 | + <Loading fallback={<i>...</i>}> |
| 85 | + <Chained /> |
| 86 | + </Loading> |
| 87 | + </div> |
| 88 | + ); |
| 89 | + }); |
| 90 | + if (interleave) otherRequestCompletes(streamContext); |
| 91 | + const html = await streamed; |
| 92 | + expect(html).toContain("b1"); |
| 93 | + // b's record: the client adopts it instead of recomputing b. |
| 94 | + expect(hydrationRecordKeys(html)).toContain(bOwner + "1"); |
| 95 | + }); |
| 96 | + } |
| 97 | +}); |
| 98 | + |
| 99 | +describe("a finished renderToString's late writes", () => { |
| 100 | + test("are dropped by its own closed latch, not the global context", async () => { |
| 101 | + let captured: any; |
| 102 | + renderToString(() => { |
| 103 | + captured = sharedConfig.context; |
| 104 | + return <div />; |
| 105 | + }); |
| 106 | + const hold = delay(10); |
| 107 | + const streamed = renderComplete(() => { |
| 108 | + const m = createMemo(async () => { |
| 109 | + await hold; |
| 110 | + return "held"; |
| 111 | + }); |
| 112 | + return ( |
| 113 | + <Loading fallback={<i>...</i>}> |
| 114 | + <b>{m()}</b> |
| 115 | + </Loading> |
| 116 | + ); |
| 117 | + }); |
| 118 | + // Another request's stream now owns the module global. |
| 119 | + expect(sharedConfig.context).not.toBe(captured); |
| 120 | + // An async value would throw if the closed render still accepted writes. |
| 121 | + expect(() => captured.serialize("late", Promise.resolve(1))).not.toThrow(); |
| 122 | + expect(() => captured.serialize("late-sync", { v: 1 })).not.toThrow(); |
| 123 | + expect(await streamed).toContain("held"); |
| 124 | + }); |
| 125 | +}); |
0 commit comments