diff --git a/.changeset/clean-exports-surface.md b/.changeset/clean-exports-surface.md new file mode 100644 index 00000000..3cd9d8d7 --- /dev/null +++ b/.changeset/clean-exports-surface.md @@ -0,0 +1,5 @@ +--- +"@sheetwrite/core": minor +--- + +Remove the unsupported `cloneCellHyperlink` and `createHyperlinkId` root exports. Hyperlink behavior remains available through the documented grid APIs. diff --git a/.changeset/light-packages-travel.md b/.changeset/light-packages-travel.md new file mode 100644 index 00000000..f1abed27 --- /dev/null +++ b/.changeset/light-packages-travel.md @@ -0,0 +1,5 @@ +--- +"@sheetwrite/core": patch +--- + +Exclude generated source maps from the published package while retaining JavaScript, declarations, styles, and unchanged browser runtime output. diff --git a/.changeset/packed-windows-glide.md b/.changeset/packed-windows-glide.md new file mode 100644 index 00000000..8acc6f5a --- /dev/null +++ b/.changeset/packed-windows-glide.md @@ -0,0 +1,6 @@ +--- +"@sheetwrite/core": patch +"@sheetwrite/wasm": minor +--- + +Pack visible-window numeric, kind, style, string-index, and conditional-format data into one validated transfer buffer, reducing ordinary window reads from seven output allocations to one. diff --git a/.changeset/quick-frames-settle.md b/.changeset/quick-frames-settle.md new file mode 100644 index 00000000..ec322dd6 --- /dev/null +++ b/.changeset/quick-frames-settle.md @@ -0,0 +1,5 @@ +--- +"@sheetwrite/core": patch +--- + +Compare render state with allocation-light scalar snapshots instead of rebuilding composite signature strings on every frame. diff --git a/.changeset/steady-store-boundaries.md b/.changeset/steady-store-boundaries.md new file mode 100644 index 00000000..ce90f808 --- /dev/null +++ b/.changeset/steady-store-boundaries.md @@ -0,0 +1,5 @@ +--- +"@sheetwrite/core": patch +--- + +Keep paged transaction admission and reference accounting isolated from dense store initialization while preserving existing resource limits. diff --git a/.changeset/warm-formulas-window.md b/.changeset/warm-formulas-window.md new file mode 100644 index 00000000..afb89b48 --- /dev/null +++ b/.changeset/warm-formulas-window.md @@ -0,0 +1,5 @@ +--- +"@sheetwrite/wasm": patch +--- + +Reuse each visible formula entry while decoding render windows, avoiding redundant formula-map probes without changing resolved values or error precedence. diff --git a/bench/results/render-diagnostics.json b/bench/results/render-diagnostics.json index 0abf9adb..5fb5ac50 100644 --- a/bench/results/render-diagnostics.json +++ b/bench/results/render-diagnostics.json @@ -1,10 +1,10 @@ { "protocolVersion": 1, - "runId": "9b53c653-199a-4c15-b545-72c34cad1de7", + "runId": "a42aaa37-341b-413f-95fd-b25c911d4a2f", "metadata": { - "commit": "83d6745a98cea4eebf23a978072d89d0fffdeb06", + "commit": "66a0df7c6288a89dfb2c8d2226a2fe0b88b3d25e", "dirty": false, - "timestamp": "2026-07-27T15:34:48.766Z", + "timestamp": "2026-08-09T14:10:47.440Z", "bunVersion": "1.3.14", "nodeVersion": "24.3.0", "browserVersion": "149.0.7827.55", @@ -26,15 +26,12 @@ "measuredSamples": 3, "warmupSamples": 1, "minimumSampleDurationMs": 100, - "rounds": 3, + "rounds": 2, "orderSeed": 1371602926, "engineOrder": [ [ "sheetwrite" ], - [ - "sheetwrite" - ], [ "sheetwrite" ] @@ -57,16 +54,17 @@ "success": true, "errorClass": null, "message": null - }, - { - "round": 3, - "engine": "sheetwrite", - "rows": 100000, - "attempt": 1, - "success": true, - "errorClass": null, - "message": null } + ], + "windowTransferScenarioOrder": [ + [ + "window-transfer.scroll.baseline", + "window-transfer.scroll.reuse-decoded-view-upper-bound" + ], + [ + "window-transfer.scroll.reuse-decoded-view-upper-bound", + "window-transfer.scroll.baseline" + ] ] }, "config": { @@ -80,42 +78,44 @@ "formula-dense.paint", "text-heavy.long-scroll", "scroll-fractional.same-window", - "geometry-unresized.1m" + "geometry-unresized.1m", + "window-transfer.scroll.baseline", + "window-transfer.scroll.reuse-decoded-view-upper-bound" ] }, "results": [ { - "runId": "9b53c653-199a-4c15-b545-72c34cad1de7", + "runId": "a42aaa37-341b-413f-95fd-b25c911d4a2f", "round": 1, "engine": "sheetwrite", "rows": 100000, "scenarioId": "formula-dense.paint", "group": "formulae", "status": "success", - "operationCount": 763, + "operationCount": 764, "rawSamples": [ { "index": 0, - "durationMs": 100.00000000186265, - "operationCount": 200, - "perOperationMs": 0.5000000000093132 + "durationMs": 118.39999999757856, + "operationCount": 196, + "perOperationMs": 0.6040816326407069 }, { "index": 1, - "durationMs": 104.90000000223517, - "operationCount": 279, - "perOperationMs": 0.3759856630904487 + "durationMs": 113.79999999888241, + "operationCount": 284, + "perOperationMs": 0.4007042253481775 }, { "index": 2, - "durationMs": 107.49999999813735, + "durationMs": 133.4000000031665, "operationCount": 284, - "perOperationMs": 0.3785211267540048 + "perOperationMs": 0.46971830987030455 } ], - "medianMs": 0.3785211267540048, - "p95Ms": 0.48785211268378237, - "madMs": 0.0025354636635561145, + "medianMs": 0.46971830987030455, + "p95Ms": 0.5906453003636667, + "madMs": 0.06901408452212704, "validation": [ { "checkpoint": "grid remains mounted and accessibility-labelled", @@ -167,9 +167,9 @@ } ], "memory": { - "beforeBytes": 20080377, - "afterBytes": 25141664, - "deltaBytes": 5061287 + "beforeBytes": 18114908, + "afterBytes": 20778717, + "deltaBytes": 2663809 }, "resources": { "compiledFormats": 0, @@ -185,7 +185,7 @@ } }, { - "runId": "9b53c653-199a-4c15-b545-72c34cad1de7", + "runId": "a42aaa37-341b-413f-95fd-b25c911d4a2f", "round": 1, "engine": "sheetwrite", "rows": 100000, @@ -196,26 +196,26 @@ "rawSamples": [ { "index": 0, - "durationMs": 3969.7000000011176, + "durationMs": 4102.5, "operationCount": 1, - "perOperationMs": 3969.7000000011176 + "perOperationMs": 4102.5 }, { "index": 1, - "durationMs": 4203.9000000003725, + "durationMs": 4484.799999999814, "operationCount": 1, - "perOperationMs": 4203.9000000003725 + "perOperationMs": 4484.799999999814 }, { "index": 2, - "durationMs": 4022.5, + "durationMs": 4140.700000000186, "operationCount": 1, - "perOperationMs": 4022.5 + "perOperationMs": 4140.700000000186 } ], - "medianMs": 4022.5, - "p95Ms": 4185.760000000335, - "madMs": 52.79999999888241, + "medianMs": 4140.700000000186, + "p95Ms": 4450.389999999851, + "madMs": 38.200000000186265, "validation": [ { "checkpoint": "grid remains mounted and accessibility-labelled", @@ -267,9 +267,9 @@ } ], "memory": { - "beforeBytes": 20250857, - "afterBytes": 48982401, - "deltaBytes": 28731544 + "beforeBytes": 18665166, + "afterBytes": 53711204, + "deltaBytes": 35046038 }, "resources": { "compiledFormats": 0, @@ -285,37 +285,37 @@ } }, { - "runId": "9b53c653-199a-4c15-b545-72c34cad1de7", + "runId": "a42aaa37-341b-413f-95fd-b25c911d4a2f", "round": 1, "engine": "sheetwrite", "rows": 100000, "scenarioId": "scroll-fractional.same-window", "group": "view-scrolling", "status": "success", - "operationCount": 424, + "operationCount": 456, "rawSamples": [ { "index": 0, - "durationMs": 100.59999998472631, - "operationCount": 145, - "perOperationMs": 0.6937931033429401 + "durationMs": 100.59999999962747, + "operationCount": 146, + "perOperationMs": 0.6890410958878594 }, { "index": 1, - "durationMs": 100.20000000484288, - "operationCount": 139, - "perOperationMs": 0.7208633093873589 + "durationMs": 100.00000000279397, + "operationCount": 159, + "perOperationMs": 0.628930817627635 }, { "index": 2, - "durationMs": 100.59999999403954, - "operationCount": 140, - "perOperationMs": 0.7185714285288538 + "durationMs": 100.3000000026077, + "operationCount": 151, + "perOperationMs": 0.664238410613296 } ], - "medianMs": 0.7185714285288538, - "p95Ms": 0.7206341213015084, - "madMs": 0.0022918808585050687, + "medianMs": 0.664238410613296, + "p95Ms": 0.6865608273604031, + "madMs": 0.02480268527456342, "validation": [ { "checkpoint": "grid remains mounted and accessibility-labelled", @@ -367,9 +367,9 @@ } ], "memory": { - "beforeBytes": 49016445, - "afterBytes": 34924577, - "deltaBytes": -14091868 + "beforeBytes": 53745508, + "afterBytes": 32630778, + "deltaBytes": -21114730 }, "resources": { "compiledFormats": 0, @@ -385,37 +385,37 @@ } }, { - "runId": "9b53c653-199a-4c15-b545-72c34cad1de7", + "runId": "a42aaa37-341b-413f-95fd-b25c911d4a2f", "round": 1, "engine": "sheetwrite", "rows": 100000, "scenarioId": "geometry-unresized.1m", "group": "geometry", "status": "success", - "operationCount": 2415151, + "operationCount": 2464559, "rawSamples": [ { "index": 0, - "durationMs": 100, - "operationCount": 843835, - "perOperationMs": 0.00011850658007785883 + "durationMs": 100.00000000651926, + "operationCount": 817443, + "perOperationMs": 0.00012233268864803937 }, { "index": 1, - "durationMs": 100.00000000186265, - "operationCount": 801253, - "perOperationMs": 0.0001248045249151799 + "durationMs": 100.00000000279397, + "operationCount": 789915, + "perOperationMs": 0.00012659589956235033 }, { "index": 2, - "durationMs": 100.00000002607703, - "operationCount": 770063, - "perOperationMs": 0.00012985950503540235 + "durationMs": 100.00000000279397, + "operationCount": 857201, + "perOperationMs": 0.00011665875331782623 } ], - "medianMs": 0.0001248045249151799, - "p95Ms": 0.0001293540070233801, - "madMs": 0.000005054980120222446, + "medianMs": 0.00012233268864803937, + "p95Ms": 0.00012616957847091923, + "madMs": 0.000004263210914310956, "validation": [ { "checkpoint": "grid remains mounted and accessibility-labelled", @@ -461,9 +461,9 @@ } ], "memory": { - "beforeBytes": 34934553, - "afterBytes": 45551173, - "deltaBytes": 10616620 + "beforeBytes": 32894994, + "afterBytes": 36901626, + "deltaBytes": 4006632 }, "resources": { "compiledFormats": 0, @@ -479,37 +479,71 @@ } }, { - "runId": "9b53c653-199a-4c15-b545-72c34cad1de7", - "round": 2, + "runId": "a42aaa37-341b-413f-95fd-b25c911d4a2f", + "round": 1, "engine": "sheetwrite", "rows": 100000, - "scenarioId": "formula-dense.paint", - "group": "formulae", + "scenarioId": "window-transfer.scroll.baseline", + "group": "view-scrolling", + "dataValidity": "product-valid", "status": "success", - "operationCount": 852, + "operationCount": 191, "rawSamples": [ { "index": 0, - "durationMs": 108.70000000111759, - "operationCount": 284, - "perOperationMs": 0.3827464788771746 + "durationMs": 100.90000000782311, + "operationCount": 67, + "perOperationMs": 1.5059701493704942, + "windowTransfer": { + "logicalFrames": 67, + "windowReadRequests": 67, + "logicalWindowReads": 67, + "copiedBytes": 229944, + "outputAllocationEvents": 67, + "copiedBytesPerLogicalFrame": 3432, + "outputAllocationEventsPerLogicalFrame": 1, + "copiedBytesPerLogicalRead": 3432, + "outputAllocationEventsPerLogicalRead": 1 + } }, { "index": 1, - "durationMs": 119.40000000037253, - "operationCount": 284, - "perOperationMs": 0.42042253521257933 + "durationMs": 101.19999999832362, + "operationCount": 66, + "perOperationMs": 1.5333333333079335, + "windowTransfer": { + "logicalFrames": 66, + "windowReadRequests": 66, + "logicalWindowReads": 66, + "copiedBytes": 226512, + "outputAllocationEvents": 66, + "copiedBytesPerLogicalFrame": 3432, + "outputAllocationEventsPerLogicalFrame": 1, + "copiedBytesPerLogicalRead": 3432, + "outputAllocationEventsPerLogicalRead": 1 + } }, { "index": 2, - "durationMs": 112.70000000298023, - "operationCount": 284, - "perOperationMs": 0.39683098592598676 + "durationMs": 101.49999999813735, + "operationCount": 58, + "perOperationMs": 1.7499999999678855, + "windowTransfer": { + "logicalFrames": 58, + "windowReadRequests": 58, + "logicalWindowReads": 58, + "copiedBytes": 199056, + "outputAllocationEvents": 58, + "copiedBytesPerLogicalFrame": 3432, + "outputAllocationEventsPerLogicalFrame": 1, + "copiedBytesPerLogicalRead": 3432, + "outputAllocationEventsPerLogicalRead": 1 + } } ], - "medianMs": 0.39683098592598676, - "p95Ms": 0.4180633802839201, - "madMs": 0.014084507048812145, + "medianMs": 1.5333333333079335, + "p95Ms": 1.7283333333018902, + "madMs": 0.027363183937439306, "validation": [ { "checkpoint": "grid remains mounted and accessibility-labelled", @@ -530,15 +564,21 @@ "passed": true }, { - "checkpoint": "formula-dense.paint resolves first visible formula", - "expected": "3", - "observed": "3", + "checkpoint": "window-transfer.scroll.baseline completes one coordinator paint", + "expected": "1", + "observed": "1", "passed": true }, { - "checkpoint": "formula-dense.paint resolves last visible formula column", - "expected": "6", - "observed": "6", + "checkpoint": "window-transfer.scroll.baseline requests one visible window", + "expected": "1", + "observed": "1", + "passed": true + }, + { + "checkpoint": "window-transfer.scroll.baseline accounts for a fresh decoded window", + "expected": "true", + "observed": "true", "passed": true }, { @@ -561,9 +601,9 @@ } ], "memory": { - "beforeBytes": 19967689, - "afterBytes": 29439016, - "deltaBytes": 9471327 + "beforeBytes": 36908438, + "afterBytes": 28074620, + "deltaBytes": -8833818 }, "resources": { "compiledFormats": 0, @@ -576,40 +616,85 @@ "mergeResources": { "indexConstructions": 0, "candidatesExamined": 0 + }, + "windowTransfer": { + "logicalFrames": 191, + "windowReadRequests": 191, + "logicalWindowReads": 191, + "copiedBytes": 655512, + "outputAllocationEvents": 191, + "copiedBytesPerLogicalFrame": 3432, + "outputAllocationEventsPerLogicalFrame": 1, + "copiedBytesPerLogicalRead": 3432, + "outputAllocationEventsPerLogicalRead": 1 } }, { - "runId": "9b53c653-199a-4c15-b545-72c34cad1de7", - "round": 2, + "runId": "a42aaa37-341b-413f-95fd-b25c911d4a2f", + "round": 1, "engine": "sheetwrite", "rows": 100000, - "scenarioId": "text-heavy.long-scroll", + "scenarioId": "window-transfer.scroll.reuse-decoded-view-upper-bound", "group": "view-scrolling", + "dataValidity": "pixel-data-invalid", "status": "success", - "operationCount": 3, + "operationCount": 454, "rawSamples": [ { "index": 0, - "durationMs": 3991.0999999996275, - "operationCount": 1, - "perOperationMs": 3991.0999999996275 + "durationMs": 100.19999999925494, + "operationCount": 154, + "perOperationMs": 0.6506493506445126, + "windowTransfer": { + "logicalFrames": 154, + "windowReadRequests": 154, + "logicalWindowReads": 0, + "copiedBytes": 0, + "outputAllocationEvents": 0, + "copiedBytesPerLogicalFrame": 0, + "outputAllocationEventsPerLogicalFrame": 0, + "copiedBytesPerLogicalRead": null, + "outputAllocationEventsPerLogicalRead": null + } }, { "index": 1, - "durationMs": 3918.7999999988824, - "operationCount": 1, - "perOperationMs": 3918.7999999988824 + "durationMs": 100.29999999608845, + "operationCount": 144, + "perOperationMs": 0.6965277777506143, + "windowTransfer": { + "logicalFrames": 144, + "windowReadRequests": 144, + "logicalWindowReads": 0, + "copiedBytes": 0, + "outputAllocationEvents": 0, + "copiedBytesPerLogicalFrame": 0, + "outputAllocationEventsPerLogicalFrame": 0, + "copiedBytesPerLogicalRead": null, + "outputAllocationEventsPerLogicalRead": null + } }, { "index": 2, - "durationMs": 3731.199999999255, - "operationCount": 1, - "perOperationMs": 3731.199999999255 + "durationMs": 100.60000000428408, + "operationCount": 156, + "perOperationMs": 0.644871794899257, + "windowTransfer": { + "logicalFrames": 156, + "windowReadRequests": 156, + "logicalWindowReads": 0, + "copiedBytes": 0, + "outputAllocationEvents": 0, + "copiedBytesPerLogicalFrame": 0, + "outputAllocationEventsPerLogicalFrame": 0, + "copiedBytesPerLogicalRead": null, + "outputAllocationEventsPerLogicalRead": null + } } ], - "medianMs": 3918.7999999988824, - "p95Ms": 3983.869999999553, - "madMs": 72.30000000074506, + "medianMs": 0.6506493506445126, + "p95Ms": 0.6919399350400041, + "madMs": 0.005777555745255625, "validation": [ { "checkpoint": "grid remains mounted and accessibility-labelled", @@ -630,15 +715,21 @@ "passed": true }, { - "checkpoint": "text-heavy.long-scroll advances through multiple row windows", - "expected": "true", - "observed": "true", + "checkpoint": "window-transfer.scroll.reuse-decoded-view-upper-bound completes one coordinator paint", + "expected": "1", + "observed": "1", "passed": true }, { - "checkpoint": "text-heavy.long-scroll retains unique long text", - "expected": "true", - "observed": "true", + "checkpoint": "window-transfer.scroll.reuse-decoded-view-upper-bound requests one visible window", + "expected": "1", + "observed": "1", + "passed": true + }, + { + "checkpoint": "window-transfer.scroll.reuse-decoded-view-upper-bound deliberately reuses pixel-data-invalid decoded data", + "expected": "{\"logicalWindowReads\":0,\"copiedBytes\":0,\"outputAllocationEvents\":0}", + "observed": "{\"logicalWindowReads\":0,\"copiedBytes\":0,\"outputAllocationEvents\":0}", "passed": true }, { @@ -661,9 +752,9 @@ } ], "memory": { - "beforeBytes": 20260401, - "afterBytes": 52970561, - "deltaBytes": 32710160 + "beforeBytes": 28098628, + "afterBytes": 38370252, + "deltaBytes": 10271624 }, "resources": { "compiledFormats": 0, @@ -676,40 +767,51 @@ "mergeResources": { "indexConstructions": 0, "candidatesExamined": 0 + }, + "windowTransfer": { + "logicalFrames": 454, + "windowReadRequests": 454, + "logicalWindowReads": 0, + "copiedBytes": 0, + "outputAllocationEvents": 0, + "copiedBytesPerLogicalFrame": 0, + "outputAllocationEventsPerLogicalFrame": 0, + "copiedBytesPerLogicalRead": null, + "outputAllocationEventsPerLogicalRead": null } }, { - "runId": "9b53c653-199a-4c15-b545-72c34cad1de7", + "runId": "a42aaa37-341b-413f-95fd-b25c911d4a2f", "round": 2, "engine": "sheetwrite", "rows": 100000, - "scenarioId": "scroll-fractional.same-window", - "group": "view-scrolling", + "scenarioId": "formula-dense.paint", + "group": "formulae", "status": "success", - "operationCount": 453, + "operationCount": 845, "rawSamples": [ { "index": 0, - "durationMs": 100.49999999627471, - "operationCount": 145, - "perOperationMs": 0.6931034482501705 + "durationMs": 109.8999999994412, + "operationCount": 284, + "perOperationMs": 0.3869718309839479 }, { "index": 1, - "durationMs": 100.79999999701977, - "operationCount": 150, - "perOperationMs": 0.6719999999801318 + "durationMs": 123.60000000149012, + "operationCount": 284, + "perOperationMs": 0.4352112676108807 }, { "index": 2, - "durationMs": 100.50000000558794, - "operationCount": 158, - "perOperationMs": 0.6360759494024553 + "durationMs": 100.30000000167638, + "operationCount": 277, + "perOperationMs": 0.3620938628219364 } ], - "medianMs": 0.6719999999801318, - "p95Ms": 0.6909931034231666, - "madMs": 0.021103448270038627, + "medianMs": 0.3869718309839479, + "p95Ms": 0.4303873239481874, + "madMs": 0.024877968162011488, "validation": [ { "checkpoint": "grid remains mounted and accessibility-labelled", @@ -730,15 +832,15 @@ "passed": true }, { - "checkpoint": "scroll-fractional.same-window uses a fractional device-pixel delta", - "expected": "true", - "observed": "true", + "checkpoint": "formula-dense.paint resolves first visible formula", + "expected": "3", + "observed": "3", "passed": true }, { - "checkpoint": "scroll-fractional.same-window keeps the logical row window", - "expected": "0", - "observed": "0", + "checkpoint": "formula-dense.paint resolves last visible formula column", + "expected": "6", + "observed": "6", "passed": true }, { @@ -761,9 +863,9 @@ } ], "memory": { - "beforeBytes": 53004617, - "afterBytes": 61620022, - "deltaBytes": 8615405 + "beforeBytes": 18111120, + "afterBytes": 24689909, + "deltaBytes": 6578789 }, "resources": { "compiledFormats": 0, @@ -779,37 +881,37 @@ } }, { - "runId": "9b53c653-199a-4c15-b545-72c34cad1de7", + "runId": "a42aaa37-341b-413f-95fd-b25c911d4a2f", "round": 2, "engine": "sheetwrite", "rows": 100000, - "scenarioId": "geometry-unresized.1m", - "group": "geometry", + "scenarioId": "text-heavy.long-scroll", + "group": "view-scrolling", "status": "success", - "operationCount": 2463523, + "operationCount": 3, "rawSamples": [ { "index": 0, - "durationMs": 100.09999998286366, - "operationCount": 738813, - "perOperationMs": 0.00013548759968065488 + "durationMs": 3966.7000000001863, + "operationCount": 1, + "perOperationMs": 3966.7000000001863 }, { "index": 1, - "durationMs": 100.00000000372529, - "operationCount": 896246, - "perOperationMs": 0.00011157650913223076 + "durationMs": 4361.399999999441, + "operationCount": 1, + "perOperationMs": 4361.399999999441 }, { "index": 2, - "durationMs": 100.00000000186265, - "operationCount": 828464, - "perOperationMs": 0.0001207053052418242 + "durationMs": 4311.299999999814, + "operationCount": 1, + "perOperationMs": 4311.299999999814 } ], - "medianMs": 0.0001207053052418242, - "p95Ms": 0.0001340093702367718, - "madMs": 0.000009128796109593435, + "medianMs": 4311.299999999814, + "p95Ms": 4356.389999999478, + "madMs": 50.09999999962747, "validation": [ { "checkpoint": "grid remains mounted and accessibility-labelled", @@ -830,9 +932,15 @@ "passed": true }, { - "checkpoint": "geometry-unresized.1m builds exact uniform geometry", - "expected": "{\"count\":1000000,\"backingStoreBytes\":0,\"totalHeight\":28000000,\"middleRow\":500000,\"middleTop\":14000000,\"lastRow\":999999,\"lastTop\":27999972}", - "observed": "{\"count\":1000000,\"backingStoreBytes\":0,\"totalHeight\":28000000,\"middleRow\":500000,\"middleTop\":14000000,\"lastRow\":999999,\"lastTop\":27999972}", + "checkpoint": "text-heavy.long-scroll advances through multiple row windows", + "expected": "true", + "observed": "true", + "passed": true + }, + { + "checkpoint": "text-heavy.long-scroll retains unique long text", + "expected": "true", + "observed": "true", "passed": true }, { @@ -855,9 +963,9 @@ } ], "memory": { - "beforeBytes": 61628694, - "afterBytes": 48585350, - "deltaBytes": -13043344 + "beforeBytes": 18658674, + "afterBytes": 55829980, + "deltaBytes": 37171306 }, "resources": { "compiledFormats": 0, @@ -873,37 +981,37 @@ } }, { - "runId": "9b53c653-199a-4c15-b545-72c34cad1de7", - "round": 3, + "runId": "a42aaa37-341b-413f-95fd-b25c911d4a2f", + "round": 2, "engine": "sheetwrite", "rows": 100000, - "scenarioId": "formula-dense.paint", - "group": "formulae", + "scenarioId": "scroll-fractional.same-window", + "group": "view-scrolling", "status": "success", - "operationCount": 731, + "operationCount": 430, "rawSamples": [ { "index": 0, - "durationMs": 106.29999999888241, - "operationCount": 284, - "perOperationMs": 0.37429577464395214 + "durationMs": 100.19999999459833, + "operationCount": 140, + "perOperationMs": 0.7157142856757024 }, { "index": 1, - "durationMs": 100, - "operationCount": 271, - "perOperationMs": 0.36900369003690037 + "durationMs": 100.69999999646097, + "operationCount": 142, + "perOperationMs": 0.7091549295525421 }, { "index": 2, - "durationMs": 100.00000000186265, - "operationCount": 176, - "perOperationMs": 0.5681818181924014 + "durationMs": 100.1000000005588, + "operationCount": 148, + "perOperationMs": 0.676351351355127 } ], - "medianMs": 0.37429577464395214, - "p95Ms": 0.5487932138375565, - "madMs": 0.005292084607051772, + "medianMs": 0.7091549295525421, + "p95Ms": 0.7150583500633864, + "madMs": 0.006559356123160276, "validation": [ { "checkpoint": "grid remains mounted and accessibility-labelled", @@ -924,15 +1032,15 @@ "passed": true }, { - "checkpoint": "formula-dense.paint resolves first visible formula", - "expected": "3", - "observed": "3", + "checkpoint": "scroll-fractional.same-window uses a fractional device-pixel delta", + "expected": "true", + "observed": "true", "passed": true }, { - "checkpoint": "formula-dense.paint resolves last visible formula column", - "expected": "6", - "observed": "6", + "checkpoint": "scroll-fractional.same-window keeps the logical row window", + "expected": "0", + "observed": "0", "passed": true }, { @@ -955,9 +1063,9 @@ } ], "memory": { - "beforeBytes": 20228497, - "afterBytes": 25818324, - "deltaBytes": 5589827 + "beforeBytes": 55864332, + "afterBytes": 32389674, + "deltaBytes": -23474658 }, "resources": { "compiledFormats": 0, @@ -973,37 +1081,37 @@ } }, { - "runId": "9b53c653-199a-4c15-b545-72c34cad1de7", - "round": 3, + "runId": "a42aaa37-341b-413f-95fd-b25c911d4a2f", + "round": 2, "engine": "sheetwrite", "rows": 100000, - "scenarioId": "text-heavy.long-scroll", - "group": "view-scrolling", + "scenarioId": "geometry-unresized.1m", + "group": "geometry", "status": "success", - "operationCount": 3, + "operationCount": 2325951, "rawSamples": [ { "index": 0, - "durationMs": 3976.60000000149, - "operationCount": 1, - "perOperationMs": 3976.60000000149 + "durationMs": 100.00000000651926, + "operationCount": 805297, + "perOperationMs": 0.00012417778783047653 }, { "index": 1, - "durationMs": 4009.5, - "operationCount": 1, - "perOperationMs": 4009.5 + "durationMs": 100.09999998565763, + "operationCount": 888185, + "perOperationMs": 0.00011270174567872418 }, { "index": 2, - "durationMs": 4007.199999999255, - "operationCount": 1, - "perOperationMs": 4007.199999999255 + "durationMs": 100.00000001490116, + "operationCount": 632469, + "perOperationMs": 0.00015811051611209587 } ], - "medianMs": 4007.199999999255, - "p95Ms": 4009.2699999999254, - "madMs": 2.300000000745058, + "medianMs": 0.00012417778783047653, + "p95Ms": 0.00015471724328393392, + "madMs": 0.000011476042151752354, "validation": [ { "checkpoint": "grid remains mounted and accessibility-labelled", @@ -1024,15 +1132,9 @@ "passed": true }, { - "checkpoint": "text-heavy.long-scroll advances through multiple row windows", - "expected": "true", - "observed": "true", - "passed": true - }, - { - "checkpoint": "text-heavy.long-scroll retains unique long text", - "expected": "true", - "observed": "true", + "checkpoint": "geometry-unresized.1m builds exact uniform geometry", + "expected": "{\"count\":1000000,\"backingStoreBytes\":0,\"totalHeight\":28000000,\"middleRow\":500000,\"middleTop\":14000000,\"lastRow\":999999,\"lastTop\":27999972}", + "observed": "{\"count\":1000000,\"backingStoreBytes\":0,\"totalHeight\":28000000,\"middleRow\":500000,\"middleTop\":14000000,\"lastRow\":999999,\"lastTop\":27999972}", "passed": true }, { @@ -1055,9 +1157,9 @@ } ], "memory": { - "beforeBytes": 20258453, - "afterBytes": 57446141, - "deltaBytes": 37187688 + "beforeBytes": 32399738, + "afterBytes": 57998650, + "deltaBytes": 25598912 }, "resources": { "compiledFormats": 0, @@ -1073,37 +1175,71 @@ } }, { - "runId": "9b53c653-199a-4c15-b545-72c34cad1de7", - "round": 3, + "runId": "a42aaa37-341b-413f-95fd-b25c911d4a2f", + "round": 2, "engine": "sheetwrite", "rows": 100000, - "scenarioId": "scroll-fractional.same-window", + "scenarioId": "window-transfer.scroll.reuse-decoded-view-upper-bound", "group": "view-scrolling", + "dataValidity": "pixel-data-invalid", "status": "success", - "operationCount": 485, + "operationCount": 413, "rawSamples": [ { "index": 0, - "durationMs": 100.09999999962747, - "operationCount": 161, - "perOperationMs": 0.6217391304324688 + "durationMs": 100.40000000689179, + "operationCount": 140, + "perOperationMs": 0.7171428571920841, + "windowTransfer": { + "logicalFrames": 140, + "windowReadRequests": 140, + "logicalWindowReads": 0, + "copiedBytes": 0, + "outputAllocationEvents": 0, + "copiedBytesPerLogicalFrame": 0, + "outputAllocationEventsPerLogicalFrame": 0, + "copiedBytesPerLogicalRead": null, + "outputAllocationEventsPerLogicalRead": null + } }, { "index": 1, - "durationMs": 100.40000000223517, - "operationCount": 167, - "perOperationMs": 0.6011976048038035 + "durationMs": 100.0000000083819, + "operationCount": 141, + "perOperationMs": 0.7092198582154745, + "windowTransfer": { + "logicalFrames": 141, + "windowReadRequests": 141, + "logicalWindowReads": 0, + "copiedBytes": 0, + "outputAllocationEvents": 0, + "copiedBytesPerLogicalFrame": 0, + "outputAllocationEventsPerLogicalFrame": 0, + "copiedBytesPerLogicalRead": null, + "outputAllocationEventsPerLogicalRead": null + } }, { "index": 2, - "durationMs": 100.40000000596046, - "operationCount": 157, - "perOperationMs": 0.6394904458978373 + "durationMs": 100.3000000026077, + "operationCount": 132, + "perOperationMs": 0.7598484848682402, + "windowTransfer": { + "logicalFrames": 132, + "windowReadRequests": 132, + "logicalWindowReads": 0, + "copiedBytes": 0, + "outputAllocationEvents": 0, + "copiedBytesPerLogicalFrame": 0, + "outputAllocationEventsPerLogicalFrame": 0, + "copiedBytesPerLogicalRead": null, + "outputAllocationEventsPerLogicalRead": null + } } ], - "medianMs": 0.6217391304324688, - "p95Ms": 0.6377153143513005, - "madMs": 0.017751315465368567, + "medianMs": 0.7171428571920841, + "p95Ms": 0.7555779221006246, + "madMs": 0.007922998976609641, "validation": [ { "checkpoint": "grid remains mounted and accessibility-labelled", @@ -1124,15 +1260,21 @@ "passed": true }, { - "checkpoint": "scroll-fractional.same-window uses a fractional device-pixel delta", - "expected": "true", - "observed": "true", + "checkpoint": "window-transfer.scroll.reuse-decoded-view-upper-bound completes one coordinator paint", + "expected": "1", + "observed": "1", "passed": true }, { - "checkpoint": "scroll-fractional.same-window keeps the logical row window", - "expected": "0", - "observed": "0", + "checkpoint": "window-transfer.scroll.reuse-decoded-view-upper-bound requests one visible window", + "expected": "1", + "observed": "1", + "passed": true + }, + { + "checkpoint": "window-transfer.scroll.reuse-decoded-view-upper-bound deliberately reuses pixel-data-invalid decoded data", + "expected": "{\"logicalWindowReads\":0,\"copiedBytes\":0,\"outputAllocationEvents\":0}", + "observed": "{\"logicalWindowReads\":0,\"copiedBytes\":0,\"outputAllocationEvents\":0}", "passed": true }, { @@ -1155,9 +1297,9 @@ } ], "memory": { - "beforeBytes": 57480193, - "afterBytes": 66643534, - "deltaBytes": 9163341 + "beforeBytes": 58005274, + "afterBytes": 34190418, + "deltaBytes": -23814856 }, "resources": { "compiledFormats": 0, @@ -1170,40 +1312,85 @@ "mergeResources": { "indexConstructions": 0, "candidatesExamined": 0 + }, + "windowTransfer": { + "logicalFrames": 413, + "windowReadRequests": 413, + "logicalWindowReads": 0, + "copiedBytes": 0, + "outputAllocationEvents": 0, + "copiedBytesPerLogicalFrame": 0, + "outputAllocationEventsPerLogicalFrame": 0, + "copiedBytesPerLogicalRead": null, + "outputAllocationEventsPerLogicalRead": null } }, { - "runId": "9b53c653-199a-4c15-b545-72c34cad1de7", - "round": 3, + "runId": "a42aaa37-341b-413f-95fd-b25c911d4a2f", + "round": 2, "engine": "sheetwrite", "rows": 100000, - "scenarioId": "geometry-unresized.1m", - "group": "geometry", + "scenarioId": "window-transfer.scroll.baseline", + "group": "view-scrolling", + "dataValidity": "product-valid", "status": "success", - "operationCount": 2512673, + "operationCount": 194, "rawSamples": [ { "index": 0, - "durationMs": 100, - "operationCount": 861993, - "perOperationMs": 0.00011601022282083497 + "durationMs": 100.69999999832362, + "operationCount": 65, + "perOperationMs": 1.5492307692049787, + "windowTransfer": { + "logicalFrames": 65, + "windowReadRequests": 65, + "logicalWindowReads": 65, + "copiedBytes": 223080, + "outputAllocationEvents": 65, + "copiedBytesPerLogicalFrame": 3432, + "outputAllocationEventsPerLogicalFrame": 1, + "copiedBytesPerLogicalRead": 3432, + "outputAllocationEventsPerLogicalRead": 1 + } }, { "index": 1, - "durationMs": 100.09999998286366, - "operationCount": 806832, - "perOperationMs": 0.00012406548077278004 + "durationMs": 100.69999999646097, + "operationCount": 64, + "perOperationMs": 1.5734374999447027, + "windowTransfer": { + "logicalFrames": 64, + "windowReadRequests": 64, + "logicalWindowReads": 64, + "copiedBytes": 219648, + "outputAllocationEvents": 64, + "copiedBytesPerLogicalFrame": 3432, + "outputAllocationEventsPerLogicalFrame": 1, + "copiedBytesPerLogicalRead": 3432, + "outputAllocationEventsPerLogicalRead": 1 + } }, { "index": 2, - "durationMs": 100.09999997913837, - "operationCount": 843848, - "perOperationMs": 0.00011862325914043569 + "durationMs": 100.8000000026077, + "operationCount": 65, + "perOperationMs": 1.5507692308093493, + "windowTransfer": { + "logicalFrames": 65, + "windowReadRequests": 65, + "logicalWindowReads": 65, + "copiedBytes": 223080, + "outputAllocationEvents": 65, + "copiedBytesPerLogicalFrame": 3432, + "outputAllocationEventsPerLogicalFrame": 1, + "copiedBytesPerLogicalRead": 3432, + "outputAllocationEventsPerLogicalRead": 1 + } } ], - "medianMs": 0.00011862325914043569, - "p95Ms": 0.00012352125860954561, - "madMs": 0.0000026130363196007254, + "medianMs": 1.5507692308093493, + "p95Ms": 1.5711706730311674, + "madMs": 0.0015384616043705446, "validation": [ { "checkpoint": "grid remains mounted and accessibility-labelled", @@ -1224,9 +1411,21 @@ "passed": true }, { - "checkpoint": "geometry-unresized.1m builds exact uniform geometry", - "expected": "{\"count\":1000000,\"backingStoreBytes\":0,\"totalHeight\":28000000,\"middleRow\":500000,\"middleTop\":14000000,\"lastRow\":999999,\"lastTop\":27999972}", - "observed": "{\"count\":1000000,\"backingStoreBytes\":0,\"totalHeight\":28000000,\"middleRow\":500000,\"middleTop\":14000000,\"lastRow\":999999,\"lastTop\":27999972}", + "checkpoint": "window-transfer.scroll.baseline completes one coordinator paint", + "expected": "1", + "observed": "1", + "passed": true + }, + { + "checkpoint": "window-transfer.scroll.baseline requests one visible window", + "expected": "1", + "observed": "1", + "passed": true + }, + { + "checkpoint": "window-transfer.scroll.baseline accounts for a fresh decoded window", + "expected": "true", + "observed": "true", "passed": true }, { @@ -1249,9 +1448,9 @@ } ], "memory": { - "beforeBytes": 66652194, - "afterBytes": 70127578, - "deltaBytes": 3475384 + "beforeBytes": 34212894, + "afterBytes": 28497072, + "deltaBytes": -5715822 }, "resources": { "compiledFormats": 0, @@ -1264,6 +1463,17 @@ "mergeResources": { "indexConstructions": 0, "candidatesExamined": 0 + }, + "windowTransfer": { + "logicalFrames": 194, + "windowReadRequests": 194, + "logicalWindowReads": 194, + "copiedBytes": 665808, + "outputAllocationEvents": 194, + "copiedBytesPerLogicalFrame": 3432, + "outputAllocationEventsPerLogicalFrame": 1, + "copiedBytesPerLogicalRead": 3432, + "outputAllocationEventsPerLogicalRead": 1 } } ], @@ -1273,28 +1483,28 @@ "round=1;engine=sheetwrite;rows=100000;scenario=geometry-unresized.1m", "round=1;engine=sheetwrite;rows=100000;scenario=scroll-fractional.same-window", "round=1;engine=sheetwrite;rows=100000;scenario=text-heavy.long-scroll", + "round=1;engine=sheetwrite;rows=100000;scenario=window-transfer.scroll.baseline", + "round=1;engine=sheetwrite;rows=100000;scenario=window-transfer.scroll.reuse-decoded-view-upper-bound", "round=2;engine=sheetwrite;rows=100000;scenario=formula-dense.paint", "round=2;engine=sheetwrite;rows=100000;scenario=geometry-unresized.1m", "round=2;engine=sheetwrite;rows=100000;scenario=scroll-fractional.same-window", "round=2;engine=sheetwrite;rows=100000;scenario=text-heavy.long-scroll", - "round=3;engine=sheetwrite;rows=100000;scenario=formula-dense.paint", - "round=3;engine=sheetwrite;rows=100000;scenario=geometry-unresized.1m", - "round=3;engine=sheetwrite;rows=100000;scenario=scroll-fractional.same-window", - "round=3;engine=sheetwrite;rows=100000;scenario=text-heavy.long-scroll" + "round=2;engine=sheetwrite;rows=100000;scenario=window-transfer.scroll.baseline", + "round=2;engine=sheetwrite;rows=100000;scenario=window-transfer.scroll.reuse-decoded-view-upper-bound" ], "observedKeys": [ "round=1;engine=sheetwrite;rows=100000;scenario=formula-dense.paint", "round=1;engine=sheetwrite;rows=100000;scenario=geometry-unresized.1m", "round=1;engine=sheetwrite;rows=100000;scenario=scroll-fractional.same-window", "round=1;engine=sheetwrite;rows=100000;scenario=text-heavy.long-scroll", + "round=1;engine=sheetwrite;rows=100000;scenario=window-transfer.scroll.baseline", + "round=1;engine=sheetwrite;rows=100000;scenario=window-transfer.scroll.reuse-decoded-view-upper-bound", "round=2;engine=sheetwrite;rows=100000;scenario=formula-dense.paint", "round=2;engine=sheetwrite;rows=100000;scenario=geometry-unresized.1m", "round=2;engine=sheetwrite;rows=100000;scenario=scroll-fractional.same-window", "round=2;engine=sheetwrite;rows=100000;scenario=text-heavy.long-scroll", - "round=3;engine=sheetwrite;rows=100000;scenario=formula-dense.paint", - "round=3;engine=sheetwrite;rows=100000;scenario=geometry-unresized.1m", - "round=3;engine=sheetwrite;rows=100000;scenario=scroll-fractional.same-window", - "round=3;engine=sheetwrite;rows=100000;scenario=text-heavy.long-scroll" + "round=2;engine=sheetwrite;rows=100000;scenario=window-transfer.scroll.baseline", + "round=2;engine=sheetwrite;rows=100000;scenario=window-transfer.scroll.reuse-decoded-view-upper-bound" ], "missingKeys": [], "duplicateKeys": [], diff --git a/bench/results/render-diagnostics.md b/bench/results/render-diagnostics.md index 164c9f73..395a4e2f 100644 --- a/bench/results/render-diagnostics.md +++ b/bench/results/render-diagnostics.md @@ -1,15 +1,15 @@ # Auditable render benchmark Protocol version: **1** -Run ID: `9b53c653-199a-4c15-b545-72c34cad1de7` +Run ID: `a42aaa37-341b-413f-95fd-b25c911d4a2f` Matrix: **complete and successful** ## Environment | Field | Value | |:--|:--| -| Commit | `83d6745a98cea4eebf23a978072d89d0fffdeb06` (clean) | -| Timestamp | 2026-07-27T15:34:48.766Z | +| Commit | `66a0df7c6288a89dfb2c8d2226a2fe0b88b3d25e` (clean) | +| Timestamp | 2026-08-09T14:10:47.440Z | | Runtime | Bun 1.3.14; Node 24.3.0 | | Browser | 149.0.7827.55 | | OS / arch | linux 7.1.3-2-cachyos / x64 | @@ -18,8 +18,8 @@ Matrix: **complete and successful** | Dataset | seed 1592639710; 100,000 rows = `fnv1a32:178eac66` | | Viewport | 640 × 480 | | Sampling | 1 excluded warmup aggregate(s), 3 measured aggregate(s), minimum 100 ms each | -| Counterbalance | seed 1371602926; round 1: sheetwrite; round 2: sheetwrite; round 3: sheetwrite | -| Browser launches | 3 attempt(s); 0 failed attempt(s), all recorded in raw JSON | +| Counterbalance | seed 1371602926; round 1: sheetwrite; round 2: sheetwrite | +| Browser launches | 2 attempt(s); 0 failed attempt(s), all recorded in raw JSON | Every cell below is linked to the raw JSON. Timings are per logical operation and use every measured sample; p95 is linearly interpolated and MAD is the median absolute deviation. Setup, cleanup, and declared warmups are excluded. @@ -27,18 +27,28 @@ Every cell below is linked to the raw JSON. Timings are per logical operation an | round | rows | scenario / raw identity | Sheetwrite | Handsontable | |---:|---:|:--|:--|:--| -| 1 | 100,000 | [`r1-100000-formula-dense.paint`](./render-results.json) | median 0.37852 ms; p95 0.48785; MAD 0.00254; 3 samples / 763 ops | -| 1 | 100,000 | [`r1-100000-text-heavy.long-scroll`](./render-results.json) | median 4022.5 ms; p95 4185.8; MAD 52.800; 3 samples / 3 ops | -| 1 | 100,000 | [`r1-100000-scroll-fractional.same-window`](./render-results.json) | median 0.71857 ms; p95 0.72063; MAD 0.00229; 3 samples / 424 ops | -| 1 | 100,000 | [`r1-100000-geometry-unresized.1m`](./render-results.json) | median 0.00012 ms; p95 0.00013; MAD 0.00001; 3 samples / 2415151 ops | -| 2 | 100,000 | [`r2-100000-formula-dense.paint`](./render-results.json) | median 0.39683 ms; p95 0.41806; MAD 0.01408; 3 samples / 852 ops | -| 2 | 100,000 | [`r2-100000-text-heavy.long-scroll`](./render-results.json) | median 3918.8 ms; p95 3983.9; MAD 72.300; 3 samples / 3 ops | -| 2 | 100,000 | [`r2-100000-scroll-fractional.same-window`](./render-results.json) | median 0.67200 ms; p95 0.69099; MAD 0.02110; 3 samples / 453 ops | -| 2 | 100,000 | [`r2-100000-geometry-unresized.1m`](./render-results.json) | median 0.00012 ms; p95 0.00013; MAD 0.00001; 3 samples / 2463523 ops | -| 3 | 100,000 | [`r3-100000-formula-dense.paint`](./render-results.json) | median 0.37430 ms; p95 0.54879; MAD 0.00529; 3 samples / 731 ops | -| 3 | 100,000 | [`r3-100000-text-heavy.long-scroll`](./render-results.json) | median 4007.2 ms; p95 4009.3; MAD 2.300; 3 samples / 3 ops | -| 3 | 100,000 | [`r3-100000-scroll-fractional.same-window`](./render-results.json) | median 0.62174 ms; p95 0.63772; MAD 0.01775; 3 samples / 485 ops | -| 3 | 100,000 | [`r3-100000-geometry-unresized.1m`](./render-results.json) | median 0.00012 ms; p95 0.00012; MAD 0.00000; 3 samples / 2512673 ops | +| 1 | 100,000 | [`r1-100000-formula-dense.paint`](./render-results.json) | median 0.46972 ms; p95 0.59065; MAD 0.06901; 3 samples / 764 ops | +| 1 | 100,000 | [`r1-100000-text-heavy.long-scroll`](./render-results.json) | median 4140.7 ms; p95 4450.4; MAD 38.200; 3 samples / 3 ops | +| 1 | 100,000 | [`r1-100000-scroll-fractional.same-window`](./render-results.json) | median 0.66424 ms; p95 0.68656; MAD 0.02480; 3 samples / 456 ops | +| 1 | 100,000 | [`r1-100000-geometry-unresized.1m`](./render-results.json) | median 0.00012 ms; p95 0.00013; MAD 0.00000; 3 samples / 2464559 ops | +| 1 | 100,000 | [`r1-100000-window-transfer.scroll.baseline`](./render-results.json) | median 1.533 ms; p95 1.728; MAD 0.02736; 3 samples / 191 ops; validity product-valid; copied/frame 3432.000 B; allocations/frame 1.000; copied/read 3432.000 B; allocations/read 1.000 | +| 1 | 100,000 | [`r1-100000-window-transfer.scroll.reuse-decoded-view-upper-bound`](./render-results.json) | median 0.65065 ms; p95 0.69194; MAD 0.00578; 3 samples / 454 ops; validity pixel-data-invalid; copied/frame 0.000 B; allocations/frame 0.000; copied/read n/a; allocations/read n/a | +| 2 | 100,000 | [`r2-100000-formula-dense.paint`](./render-results.json) | median 0.38697 ms; p95 0.43039; MAD 0.02488; 3 samples / 845 ops | +| 2 | 100,000 | [`r2-100000-text-heavy.long-scroll`](./render-results.json) | median 4311.3 ms; p95 4356.4; MAD 50.100; 3 samples / 3 ops | +| 2 | 100,000 | [`r2-100000-scroll-fractional.same-window`](./render-results.json) | median 0.70915 ms; p95 0.71506; MAD 0.00656; 3 samples / 430 ops | +| 2 | 100,000 | [`r2-100000-geometry-unresized.1m`](./render-results.json) | median 0.00012 ms; p95 0.00015; MAD 0.00001; 3 samples / 2325951 ops | +| 2 | 100,000 | [`r2-100000-window-transfer.scroll.baseline`](./render-results.json) | median 1.551 ms; p95 1.571; MAD 0.00154; 3 samples / 194 ops; validity product-valid; copied/frame 3432.000 B; allocations/frame 1.000; copied/read 3432.000 B; allocations/read 1.000 | +| 2 | 100,000 | [`r2-100000-window-transfer.scroll.reuse-decoded-view-upper-bound`](./render-results.json) | median 0.71714 ms; p95 0.75558; MAD 0.00792; 3 samples / 413 ops; validity pixel-data-invalid; copied/frame 0.000 B; allocations/frame 0.000; copied/read n/a; allocations/read n/a | + +## Visible-window transfer diagnostic + +> The reuse upper bound deliberately paints a prior decoded view. Its pixels/data are invalid and it is not a product-valid rendering result. + +Counterbalanced scenario order: round 1: window-transfer.scroll.baseline → window-transfer.scroll.reuse-decoded-view-upper-bound; round 2: window-transfer.scroll.reuse-decoded-view-upper-bound → window-transfer.scroll.baseline + +| rows | baseline repetition medians | upper-bound repetition medians | estimated transfer cost | cross-variant spread | maximum within-variant spread | resolution | +|---:|:--|:--|---:|---:|---:|:--| +| 100,000 | 1.533, 1.551 ms | 0.65065, 0.71714 ms | 0.85816 ms | 0.85816 ms | 0.06649 ms | resolved | ## Reproduce diff --git a/bench/schema/wasm-profile-build.schema.json b/bench/schema/wasm-profile-build.schema.json new file mode 100644 index 00000000..50cf9dd3 --- /dev/null +++ b/bench/schema/wasm-profile-build.schema.json @@ -0,0 +1,95 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://sheetwrite.dev/schemas/wasm-profile-build-v1.json", + "title": "Sheetwrite fixed-Rust WASM post-link build capture", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "protocol", + "provenance", + "rustProfile", + "postLink", + "variants", + "completion" + ], + "properties": { + "schemaVersion": { "const": 1 }, + "protocol": { "const": "wasm-post-link-build-capture-v1" }, + "provenance": { + "type": "object", + "additionalProperties": false, + "required": [ + "commit", + "dirty", + "timestamp", + "host", + "rustc", + "cargo", + "wasmPack", + "wasmOptVersion", + "wasmOptSha256", + "bun" + ], + "properties": { + "commit": { "type": "string", "pattern": "^[0-9a-f]{40}$" }, + "dirty": { "const": false }, + "timestamp": { "type": "string", "format": "date-time" }, + "host": { "type": "string", "minLength": 1 }, + "rustc": { "type": "string", "minLength": 1 }, + "cargo": { "type": "string", "minLength": 1 }, + "wasmPack": { "type": "string", "minLength": 1 }, + "wasmOptVersion": { "type": "string", "minLength": 1 }, + "wasmOptSha256": { "$ref": "#/$defs/sha256" }, + "bun": { "type": "string", "minLength": 1 } + } + }, + "rustProfile": { + "const": { "optLevel": 3, "lto": "fat", "codegenUnits": 1 } + }, + "postLink": { + "type": "object", + "additionalProperties": false, + "required": ["inputWasmSha256", "compression"], + "properties": { + "inputWasmSha256": { "$ref": "#/$defs/sha256" }, + "compression": { "const": { "gzipLevel": 9, "brotliQuality": 11, "brotliMode": "generic" } } + } + }, + "variants": { + "type": "array", + "minItems": 3, + "maxItems": 3, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["wasmOpt", "inputWasmSha256", "artifactPath", "artifact"], + "properties": { + "wasmOpt": { "enum": ["-O3", "-Os", "-Oz"] }, + "inputWasmSha256": { "$ref": "#/$defs/sha256" }, + "artifactPath": { "type": "string", "minLength": 1 }, + "artifact": { + "type": "object", + "additionalProperties": false, + "required": ["rawBytes", "gzipBytes", "brotliBytes", "sha256"], + "properties": { + "rawBytes": { "type": "integer", "minimum": 1 }, + "gzipBytes": { "type": "integer", "minimum": 1 }, + "brotliBytes": { "type": "integer", "minimum": 1 }, + "sha256": { "$ref": "#/$defs/sha256" } + } + } + } + } + }, + "completion": { + "const": { + "status": "gates-required", + "required": ["formulaGate", "renderGate", "storeOnlyProbe"] + } + } + }, + "$defs": { + "sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" } + } +} diff --git a/bench/schema/wasm-profile-gates.schema.json b/bench/schema/wasm-profile-gates.schema.json new file mode 100644 index 00000000..86c9a4b3 --- /dev/null +++ b/bench/schema/wasm-profile-gates.schema.json @@ -0,0 +1,65 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://sheetwrite.dev/schemas/wasm-profile-gates-v1.json", + "title": "Sheetwrite WASM profile gate captures", + "type": "object", + "additionalProperties": false, + "required": ["schemaVersion", "protocol", "gate", "captures"], + "properties": { + "schemaVersion": { "const": 1 }, + "protocol": { "const": "wasm-post-link-gate-capture-v1" }, + "gate": { "enum": ["formula", "render"] }, + "captures": { + "type": "array", + "minItems": 3, + "maxItems": 3, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "wasmOpt", + "status", + "commands", + "exitCode", + "artifactPath", + "evidenceSha256", + "wasmSha256", + "commit", + "runnerFingerprint" + ], + "properties": { + "wasmOpt": { "enum": ["-O3", "-Os", "-Oz"] }, + "status": { "enum": ["passed", "failed"] }, + "commands": { + "type": "array", + "minItems": 1, + "items": { + "type": "array", + "minItems": 1, + "items": { "type": "string", "minLength": 1 } + } + }, + "exitCode": { "type": "integer", "minimum": 0 }, + "artifactPath": { "type": "string", "minLength": 1 }, + "evidenceSha256": { "$ref": "#/$defs/sha256" }, + "wasmSha256": { "$ref": "#/$defs/sha256" }, + "commit": { "type": "string", "pattern": "^[0-9a-f]{40}$" }, + "runnerFingerprint": { "$ref": "#/$defs/sha256" } + }, + "allOf": [ + { + "if": { "properties": { "status": { "const": "passed" } } }, + "then": { "properties": { "exitCode": { "const": 0 } } } + }, + { + "if": { "properties": { "status": { "const": "failed" } } }, + "then": { "properties": { "exitCode": { "minimum": 1 } } } + } + ] + } + } + }, + "$defs": { + "sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" } + } +} diff --git a/bench/schema/wasm-profile-matrix.schema.json b/bench/schema/wasm-profile-matrix.schema.json new file mode 100644 index 00000000..3bc659e3 --- /dev/null +++ b/bench/schema/wasm-profile-matrix.schema.json @@ -0,0 +1,270 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://sheetwrite.dev/schemas/wasm-profile-matrix-v1.json", + "title": "Sheetwrite controlled WASM post-link profile matrix", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "protocol", + "provenance", + "rustProfile", + "postLink", + "decisionPolicy", + "captureOrder", + "variants", + "storeOnlyProbe" + ], + "properties": { + "schemaVersion": { "const": 1 }, + "protocol": { "const": "wasm-post-link-profile-matrix-v1" }, + "provenance": { + "type": "object", + "additionalProperties": false, + "required": [ + "commit", + "dirty", + "timestamp", + "host", + "rustc", + "cargo", + "wasmPack", + "wasmOptVersion", + "wasmOptSha256", + "bun" + ], + "properties": { + "commit": { "$ref": "#/$defs/commit" }, + "dirty": { "const": false }, + "timestamp": { "type": "string", "format": "date-time" }, + "host": { "type": "string", "minLength": 1 }, + "rustc": { "type": "string", "minLength": 1 }, + "cargo": { "type": "string", "minLength": 1 }, + "wasmPack": { "type": "string", "minLength": 1 }, + "wasmOptVersion": { "type": "string", "minLength": 1 }, + "wasmOptSha256": { "$ref": "#/$defs/sha256" }, + "bun": { "type": "string", "minLength": 1 } + } + }, + "rustProfile": { + "type": "object", + "additionalProperties": false, + "required": ["optLevel", "lto", "codegenUnits"], + "properties": { + "optLevel": { "const": 3 }, + "lto": { "const": "fat" }, + "codegenUnits": { "const": 1 } + } + }, + "postLink": { + "type": "object", + "additionalProperties": false, + "required": ["inputWasmSha256", "compression"], + "properties": { + "inputWasmSha256": { "$ref": "#/$defs/sha256" }, + "compression": { + "type": "object", + "additionalProperties": false, + "required": ["gzipLevel", "brotliQuality", "brotliMode"], + "properties": { + "gzipLevel": { "const": 9 }, + "brotliQuality": { "const": 11 }, + "brotliMode": { "const": "generic" } + } + } + } + }, + "decisionPolicy": { + "const": { + "sizeMetric": "brotliBytes", + "requireFormulaGate": true, + "requireRenderGate": true + } + }, + "captureOrder": { + "type": "object", + "additionalProperties": false, + "required": ["formula", "render"], + "properties": { + "formula": { "$ref": "#/$defs/profileOrder" }, + "render": { "$ref": "#/$defs/profileOrder" } + } + }, + "variants": { + "type": "array", + "minItems": 3, + "maxItems": 3, + "items": { "$ref": "#/$defs/variant" } + }, + "storeOnlyProbe": { "$ref": "#/$defs/storeOnlyProbe" } + }, + "$defs": { + "sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "commit": { "type": "string", "pattern": "^[0-9a-f]{40}$" }, + "profile": { "enum": ["-O3", "-Os", "-Oz"] }, + "profileOrder": { + "type": "array", + "minItems": 3, + "maxItems": 3, + "uniqueItems": true, + "items": { "$ref": "#/$defs/profile" }, + "allOf": [ + { "contains": { "const": "-O3" } }, + { "contains": { "const": "-Os" } }, + { "contains": { "const": "-Oz" } } + ] + }, + "gate": { + "type": "object", + "additionalProperties": false, + "required": [ + "status", + "commands", + "exitCode", + "artifactPath", + "evidenceSha256", + "wasmSha256", + "commit", + "runnerFingerprint" + ], + "properties": { + "status": { "enum": ["passed", "failed"] }, + "commands": { + "type": "array", + "minItems": 1, + "items": { + "type": "array", + "minItems": 1, + "items": { "type": "string", "minLength": 1 } + } + }, + "exitCode": { "type": "integer", "minimum": 0 }, + "artifactPath": { "type": "string", "minLength": 1 }, + "evidenceSha256": { "$ref": "#/$defs/sha256" }, + "wasmSha256": { "$ref": "#/$defs/sha256" }, + "commit": { "$ref": "#/$defs/commit" }, + "runnerFingerprint": { "$ref": "#/$defs/sha256" } + }, + "allOf": [ + { + "if": { "properties": { "status": { "const": "passed" } } }, + "then": { "properties": { "exitCode": { "const": 0 } } } + }, + { + "if": { "properties": { "status": { "const": "failed" } } }, + "then": { "properties": { "exitCode": { "minimum": 1 } } } + } + ] + }, + "variant": { + "type": "object", + "additionalProperties": false, + "required": ["wasmOpt", "inputWasmSha256", "artifact", "formulaGate", "renderGate"], + "properties": { + "wasmOpt": { "$ref": "#/$defs/profile" }, + "inputWasmSha256": { "$ref": "#/$defs/sha256" }, + "artifact": { + "type": "object", + "additionalProperties": false, + "required": ["rawBytes", "gzipBytes", "brotliBytes", "sha256"], + "properties": { + "rawBytes": { "type": "integer", "minimum": 1 }, + "gzipBytes": { "type": "integer", "minimum": 1 }, + "brotliBytes": { "type": "integer", "minimum": 1 }, + "sha256": { "$ref": "#/$defs/sha256" } + } + }, + "formulaGate": { "$ref": "#/$defs/gate" }, + "renderGate": { "$ref": "#/$defs/gate" } + } + }, + "blocker": { + "type": "object", + "additionalProperties": false, + "required": ["id", "kind", "owner", "dependency", "reason"], + "properties": { + "id": { "enum": ["formula-entry-ast", "dependency-index"] }, + "kind": { "const": "dependency-cycle" }, + "owner": { "type": "string", "minLength": 1 }, + "dependency": { "type": "string", "minLength": 1 }, + "reason": { "type": "string", "minLength": 1 } + } + }, + "storeOnlyProbe": { + "type": "object", + "additionalProperties": false, + "required": [ + "provenance", + "command", + "outcome", + "exitCode", + "stdoutSha256", + "stderrSha256", + "blockers" + ], + "properties": { + "provenance": { + "type": "object", + "additionalProperties": false, + "required": ["commit", "dirty", "timestamp"], + "properties": { + "commit": { "$ref": "#/$defs/commit" }, + "dirty": { "const": false }, + "timestamp": { "type": "string", "format": "date-time" } + } + }, + "command": { + "const": [ + "cargo", + "check", + "--target", + "wasm32-unknown-unknown", + "--no-default-features", + "--message-format=json" + ] + }, + "outcome": { "enum": ["compiled", "blocked", "failed"] }, + "exitCode": { "type": "integer", "minimum": 0 }, + "stdoutSha256": { "$ref": "#/$defs/sha256" }, + "stderrSha256": { "$ref": "#/$defs/sha256" }, + "blockers": { "type": "array", "items": { "$ref": "#/$defs/blocker" } } + }, + "allOf": [ + { + "if": { "properties": { "outcome": { "const": "compiled" } } }, + "then": { + "properties": { + "exitCode": { "const": 0 }, + "blockers": { "maxItems": 0 } + } + } + }, + { + "if": { "properties": { "outcome": { "const": "blocked" } } }, + "then": { + "properties": { + "exitCode": { "minimum": 1 }, + "blockers": { + "minItems": 2, + "maxItems": 2, + "allOf": [ + { "contains": { "properties": { "id": { "const": "formula-entry-ast" } } } }, + { "contains": { "properties": { "id": { "const": "dependency-index" } } } } + ] + } + } + } + }, + { + "if": { "properties": { "outcome": { "const": "failed" } } }, + "then": { + "properties": { + "exitCode": { "minimum": 1 }, + "blockers": { "maxItems": 0 } + } + } + } + ] + } + } +} diff --git a/bench/schema/wasm-store-only-probe.schema.json b/bench/schema/wasm-store-only-probe.schema.json new file mode 100644 index 00000000..ea2a74a9 --- /dev/null +++ b/bench/schema/wasm-store-only-probe.schema.json @@ -0,0 +1,86 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://sheetwrite.dev/schemas/wasm-store-only-probe-v1.json", + "title": "Sheetwrite store-only compile probe", + "type": "object", + "additionalProperties": false, + "required": [ + "provenance", + "command", + "outcome", + "exitCode", + "stdoutSha256", + "stderrSha256", + "blockers" + ], + "properties": { + "provenance": { + "type": "object", + "additionalProperties": false, + "required": ["commit", "dirty", "timestamp"], + "properties": { + "commit": { "type": "string", "pattern": "^[0-9a-f]{40}$" }, + "dirty": { "const": false }, + "timestamp": { "type": "string", "format": "date-time" } + } + }, + "command": { + "const": [ + "cargo", + "check", + "--target", + "wasm32-unknown-unknown", + "--no-default-features", + "--message-format=json" + ] + }, + "outcome": { "enum": ["compiled", "blocked", "failed"] }, + "exitCode": { "type": "integer", "minimum": 0 }, + "stdoutSha256": { "$ref": "#/$defs/sha256" }, + "stderrSha256": { "$ref": "#/$defs/sha256" }, + "blockers": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "kind", "owner", "dependency", "reason"], + "properties": { + "id": { "enum": ["formula-entry-ast", "dependency-index"] }, + "kind": { "const": "dependency-cycle" }, + "owner": { "type": "string", "minLength": 1 }, + "dependency": { "type": "string", "minLength": 1 }, + "reason": { "type": "string", "minLength": 1 } + } + } + } + }, + "allOf": [ + { + "if": { "properties": { "outcome": { "const": "compiled" } } }, + "then": { "properties": { "exitCode": { "const": 0 }, "blockers": { "maxItems": 0 } } } + }, + { + "if": { "properties": { "outcome": { "const": "blocked" } } }, + "then": { + "properties": { + "exitCode": { "minimum": 1 }, + "blockers": { + "minItems": 2, + "maxItems": 2, + "allOf": [ + { "contains": { "properties": { "id": { "const": "formula-entry-ast" } } } }, + { "contains": { "properties": { "id": { "const": "dependency-index" } } } } + ] + } + } + } + }, + { + "if": { "properties": { "outcome": { "const": "failed" } } }, + "then": { "properties": { "exitCode": { "minimum": 1 }, "blockers": { "maxItems": 0 } } } + } + ], + "$defs": { + "sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" } + } +} diff --git a/bench/src/billion-cell-bench.ts b/bench/src/billion-cell-bench.ts index db50e2ca..2455db61 100644 --- a/bench/src/billion-cell-bench.ts +++ b/bench/src/billion-cell-bench.ts @@ -1,4 +1,5 @@ import ts from "typescript"; +import typeScriptManifest from "../../node_modules/typescript/package.json" with { type: "json" }; import { DatasourceController } from "../../packages/core/src/datasource-controller.js"; import { initSheetwrite } from "../../packages/core/src/grid.js"; import { @@ -1495,7 +1496,7 @@ export function validateBillionCellBenchmark( const toolchain = record(artifact.toolchain, "toolchain"); exact(toolchain.runtime, "bun", "toolchain.runtime"); exact(toolchain.bunVersion, Bun.version, "toolchain.bunVersion"); - exact(toolchain.typescriptVersion, ts.version, "toolchain.typescriptVersion"); + exact(toolchain.typescriptVersion, typeScriptManifest.version, "toolchain.typescriptVersion"); exact(toolchain.platform, process.platform, "toolchain.platform"); exact(toolchain.architecture, process.arch, "toolchain.architecture"); exact( diff --git a/bench/src/render-bench.ts b/bench/src/render-bench.ts index 921ce85b..efcc2716 100644 --- a/bench/src/render-bench.ts +++ b/bench/src/render-bench.ts @@ -19,6 +19,7 @@ import { getNumberFormatResourceStatsForTest, resetNumberFormatResourcesForTest, } from "../../packages/core/src/number-format.js"; +import type { InstrumentedVisibleWindowView } from "../../packages/core/src/store/window-reader.js"; import "handsontable/styles/handsontable.css"; import "handsontable/styles/ht-theme-main.css"; import { COLUMNS, type ColumnarDataset, datasetChecksum, makeColumnar, toAoA } from "./dataset.js"; @@ -36,6 +37,7 @@ import { type RenderResourceMetrics, type ScenarioId, type ScenarioResult, + WINDOW_TRANSFER_SCENARIO_IDS, } from "./render-protocol.js"; import { type CellSelection, @@ -44,6 +46,8 @@ import { type RenderBenchAdapter, runRenderScenario, type ScrollObservation, + type WindowReadDiagnosticMode, + type WindowTransferCounters, } from "./render-scenarios.js"; const SHEET = "bench"; @@ -62,6 +66,7 @@ interface PageConfiguration { readonly minimumSampleDurationMs: number; readonly runId: string; readonly round: number; + readonly windowTransferDiagnostic: boolean; } declare global { @@ -119,7 +124,19 @@ class SheetwriteAdapter implements RenderBenchAdapter { private readonly dataset: ColumnarDataset; private formulaDenseRows = 0; private textHeavyRows = 0; - constructor(dataset: ColumnarDataset) { + private windowReadDiagnosticMode: WindowReadDiagnosticMode = "baseline"; + private priorDecodedView: InstrumentedVisibleWindowView | undefined; + private windowTransferCountersState = { + logicalFrames: 0, + windowReadRequests: 0, + logicalWindowReads: 0, + copiedBytes: 0, + outputAllocationEvents: 0, + }; + constructor( + dataset: ColumnarDataset, + private readonly windowTransferDiagnostic: boolean, + ) { this.dataset = dataset; this.initialRowCount = dataset.rowCount; this.data = { @@ -140,6 +157,47 @@ class SheetwriteAdapter implements RenderBenchAdapter { workbook: makeWorkbook(this.initialRowCount), data: this.data, }); + if (this.windowTransferDiagnostic) this.installWindowTransferDiagnostic(); + } + + private installWindowTransferDiagnostic(): void { + const originalRead = this.grid.store.getVisibleWindow.bind(this.grid.store); + this.grid.store.getVisibleWindow = ((...args) => { + this.windowTransferCountersState.windowReadRequests++; + if (this.windowReadDiagnosticMode === "reuse-decoded-view-upper-bound") { + if (!this.priorDecodedView) { + throw new Error("window-transfer upper bound has no prior decoded view"); + } + return this.priorDecodedView; + } + const view = originalRead(...args) as InstrumentedVisibleWindowView; + const copiedBytes = view.ffiOutputBytes; + const outputAllocationEvents = view.ffiOutputAllocationEvents; + if ( + typeof copiedBytes !== "number" || + !Number.isSafeInteger(copiedBytes) || + copiedBytes < 0 || + typeof outputAllocationEvents !== "number" || + !Number.isSafeInteger(outputAllocationEvents) || + outputAllocationEvents <= 0 + ) { + throw new Error("visible-window read omitted exact output transfer counters"); + } + this.windowTransferCountersState.logicalWindowReads++; + this.windowTransferCountersState.copiedBytes += copiedBytes; + this.windowTransferCountersState.outputAllocationEvents += outputAllocationEvents; + this.priorDecodedView = view; + return view; + }) as typeof this.grid.store.getVisibleWindow; + + const renderer = Reflect.get(this.grid, "renderer") as { + paint(view: InstrumentedVisibleWindowView): void; + }; + const originalPaint = renderer.paint.bind(renderer); + renderer.paint = (view): void => { + this.windowTransferCountersState.logicalFrames++; + originalPaint(view); + }; } isMountedAndAccessible(): boolean { @@ -385,6 +443,33 @@ class SheetwriteAdapter implements RenderBenchAdapter { return getMergeIndexResourceStatsForTest(); } + setWindowReadDiagnosticMode(mode: WindowReadDiagnosticMode): void { + if (!this.windowTransferDiagnostic) { + throw new Error("window-transfer mode requires benchmark diagnostic configuration"); + } + if (mode === "reuse-decoded-view-upper-bound" && !this.priorDecodedView) { + this.windowReadDiagnosticMode = "baseline"; + this.grid.store.getVisibleWindow( + SHEET, + { start: 0, end: Math.min(32, this.initialRowCount) }, + Array.from({ length: this.colCount }, (_, column) => column), + ); + } + this.windowReadDiagnosticMode = mode; + } + + resetWindowTransferCounters(): void { + this.windowTransferCountersState.logicalFrames = 0; + this.windowTransferCountersState.windowReadRequests = 0; + this.windowTransferCountersState.logicalWindowReads = 0; + this.windowTransferCountersState.copiedBytes = 0; + this.windowTransferCountersState.outputAllocationEvents = 0; + } + + windowTransferCounters(): WindowTransferCounters { + return { ...this.windowTransferCountersState }; + } + destroy(): void { this.grid.destroy(); } @@ -643,6 +728,24 @@ class HandsontableAdapter implements RenderBenchAdapter { return getMergeIndexResourceStatsForTest(); } + setWindowReadDiagnosticMode(_mode: WindowReadDiagnosticMode): void { + throw new Error("window-transfer diagnostics support only sheetwrite"); + } + + resetWindowTransferCounters(): void { + throw new Error("window-transfer diagnostics support only sheetwrite"); + } + + windowTransferCounters(): WindowTransferCounters { + return { + logicalFrames: 0, + windowReadRequests: 0, + logicalWindowReads: 0, + copiedBytes: 0, + outputAllocationEvents: 0, + }; + } + destroy(): void { this.hot.destroy(); } @@ -686,6 +789,7 @@ function teardownFailures(results: readonly ScenarioResult[], error: unknown): S rows: result.rows, scenarioId: result.scenarioId, group: result.group, + ...(result.dataValidity === undefined ? {} : { dataValidity: result.dataValidity }), status: "failed", stage: "teardown", errorClass: normalized.name || "Error", @@ -721,7 +825,7 @@ async function run(configuration: PageConfiguration): Promise { let adapter: RenderBenchAdapter = configuration.engine === "sheetwrite" - ? new SheetwriteAdapter(dataset) + ? new SheetwriteAdapter(dataset, configuration.windowTransferDiagnostic) : new HandsontableAdapter(dataset); window.__benchStage = "mount"; setStatus(`mounting ${configuration.engine}`); @@ -772,7 +876,7 @@ async function run(configuration: PageConfiguration): Promise { host.replaceChildren(); adapter = configuration.engine === "sheetwrite" - ? new SheetwriteAdapter(dataset) + ? new SheetwriteAdapter(dataset, configuration.windowTransferDiagnostic) : new HandsontableAdapter(dataset); adapter.mount(host); await settle(); @@ -820,6 +924,30 @@ function readConfiguration(params: URLSearchParams): PageConfiguration { throw new Error(`unknown render benchmark scenario: ${scenarioId}`); } } + const diagnostic = params.get("diagnostic"); + if (diagnostic !== null && diagnostic !== "window-transfer") { + throw new Error("unknown render benchmark diagnostic configuration"); + } + const windowTransferDiagnostic = diagnostic === "window-transfer"; + const requestedWindowTransferScenarios = requestedScenarios.filter((scenario) => + WINDOW_TRANSFER_SCENARIO_IDS.includes( + scenario as (typeof WINDOW_TRANSFER_SCENARIO_IDS)[number], + ), + ); + if ( + requestedWindowTransferScenarios.length > 0 && + (!windowTransferDiagnostic || + engine !== "sheetwrite" || + requestedWindowTransferScenarios.length !== WINDOW_TRANSFER_SCENARIO_IDS.length || + !WINDOW_TRANSFER_SCENARIO_IDS.every((scenario) => requestedScenarios.includes(scenario))) + ) { + throw new Error( + "window-transfer scenarios require the complete sheetwrite benchmark diagnostic pair", + ); + } + if (windowTransferDiagnostic && requestedWindowTransferScenarios.length === 0) { + throw new Error("window-transfer diagnostic configuration requires its scenario pair"); + } return { engine, scenarios: requestedScenarios as ScenarioId[], @@ -832,6 +960,7 @@ function readConfiguration(params: URLSearchParams): PageConfiguration { ), runId, round: positiveInteger(params.get("round"), 1), + windowTransferDiagnostic, }; } diff --git a/bench/src/render-driver.ts b/bench/src/render-driver.ts index 2d2de345..9352cd5f 100644 --- a/bench/src/render-driver.ts +++ b/bench/src/render-driver.ts @@ -27,8 +27,11 @@ import { renderBenchmarkMarkdown, type ScenarioId, type ScenarioResult, + scenarioDataValidity, scenarioGroup, summarizeCompleteness, + WINDOW_TRANSFER_ORDER_SEED, + WINDOW_TRANSFER_SCENARIO_IDS, } from "./render-protocol.js"; import { counterbalancedOrder } from "./stats.js"; @@ -68,6 +71,7 @@ interface CombinationConfiguration { readonly timeoutMs: number; readonly datasetHash: string; readonly browserExecutable?: string; + readonly windowTransferDiagnostic?: boolean; } interface CombinationOutput { @@ -198,6 +202,7 @@ function failureForScenario( partialSamples: FailedScenario["partialSamples"] = [], ): FailedScenario { const failure = normalizedError(error); + const dataValidity = scenarioDataValidity(scenarioId); return { runId: configuration.runId, round: configuration.round, @@ -205,6 +210,7 @@ function failureForScenario( rows: configuration.rows, scenarioId, group: scenarioGroup(scenarioId), + ...(dataValidity === undefined ? {} : { dataValidity }), status: "failed", stage, errorClass: failure.name || "Error", @@ -476,6 +482,9 @@ async function runCombination( url.searchParams.set("samples", String(configuration.measuredSamples)); url.searchParams.set("warmups", String(configuration.warmupSamples)); url.searchParams.set("minimumSampleMs", String(configuration.minimumSampleDurationMs)); + if (configuration.windowTransferDiagnostic) { + url.searchParams.set("diagnostic", "window-transfer"); + } await page.goto(url.href, { waitUntil: "load", timeout: configuration.timeoutMs }); stage = "mount"; await page.waitForFunction(() => window.__benchDone === true, undefined, { @@ -604,6 +613,13 @@ async function runDriver(args: readonly string[]): Promise { configuration.rounds, RENDER_ORDER_SEED, ); + const windowTransferScenarioOrder = configuration.diagnostic + ? counterbalancedOrder( + WINDOW_TRANSFER_SCENARIO_IDS, + configuration.rounds, + WINDOW_TRANSFER_ORDER_SEED, + ) + : undefined; const datasetHashes: Record = {}; for (const rows of configuration.rows) { datasetHashes[String(rows)] = datasetChecksum(makeColumnar(rows, DEFAULT_SEED)); @@ -630,6 +646,18 @@ async function runDriver(args: readonly string[]): Promise { try { for (let roundIndex = 0; roundIndex < engineOrder.length; roundIndex++) { const round = roundIndex + 1; + const scenarios = + windowTransferScenarioOrder === undefined + ? configuration.scenarios + : [ + ...configuration.scenarios.filter( + (scenario) => + !WINDOW_TRANSFER_SCENARIO_IDS.includes( + scenario as (typeof WINDOW_TRANSFER_SCENARIO_IDS)[number], + ), + ), + ...windowTransferScenarioOrder[roundIndex]!, + ]; for (const engine of engineOrder[roundIndex]!) { for (const rows of configuration.rows) { process.stderr.write( @@ -640,13 +668,14 @@ async function runDriver(args: readonly string[]): Promise { round, engine, rows, - scenarios: configuration.scenarios, + scenarios, measuredSamples: configuration.measuredSamples, warmupSamples: configuration.warmupSamples, minimumSampleDurationMs: configuration.minimumSampleDurationMs, timeoutMs: configuration.timeoutMs, datasetHash: datasetHashes[String(rows)]!, browserExecutable: configuration.browserExecutable, + windowTransferDiagnostic: configuration.diagnostic, }); results.push(...output.results); launchAttempts.push(...output.launchAttempts); @@ -691,6 +720,7 @@ async function runDriver(args: readonly string[]): Promise { orderSeed: RENDER_ORDER_SEED, engineOrder, launchAttempts, + ...(windowTransferScenarioOrder === undefined ? {} : { windowTransferScenarioOrder }), }, config, results, diff --git a/bench/src/render-protocol.ts b/bench/src/render-protocol.ts index b03adfeb..1e790088 100644 --- a/bench/src/render-protocol.ts +++ b/bench/src/render-protocol.ts @@ -1,11 +1,21 @@ import type { AggregateSample } from "./stats.js"; -import { summarizeFinite } from "./stats.js"; +import { counterbalancedOrder, summarizeFinite } from "./stats.js"; export const RENDER_PROTOCOL_VERSION = 1; export const RENDER_MINIMUM_SAMPLE_MS = 100; export const RENDER_MAX_LAUNCH_ATTEMPTS = 2; export const RENDER_VIEWPORT = { width: 640, height: 480 } as const; export const RENDER_ORDER_SEED = 0x51c0ffee; +export const WINDOW_TRANSFER_ORDER_SEED = 0x57494e44; +export const WINDOW_TRANSFER_BASELINE_SCENARIO_ID = "window-transfer.scroll.baseline" as const; +export const WINDOW_TRANSFER_UPPER_BOUND_SCENARIO_ID = + "window-transfer.scroll.reuse-decoded-view-upper-bound" as const; +export const WINDOW_TRANSFER_SCENARIO_IDS = [ + WINDOW_TRANSFER_BASELINE_SCENARIO_ID, + WINDOW_TRANSFER_UPPER_BOUND_SCENARIO_ID, +] as const; +export type WindowTransferScenarioId = (typeof WINDOW_TRANSFER_SCENARIO_IDS)[number]; +export type RenderDataValidity = "product-valid" | "pixel-data-invalid"; export const ENGINE_IDS = ["sheetwrite", "handsontable"] as const; export type EngineId = (typeof ENGINE_IDS)[number]; @@ -42,11 +52,19 @@ export const DIAGNOSTIC_RENDER_SCENARIOS = [ { id: "text-heavy.long-scroll", group: "view-scrolling" }, { id: "scroll-fractional.same-window", group: "view-scrolling" }, { id: "geometry-unresized.1m", group: "geometry" }, + { id: WINDOW_TRANSFER_BASELINE_SCENARIO_ID, group: "view-scrolling" }, + { id: WINDOW_TRANSFER_UPPER_BOUND_SCENARIO_ID, group: "view-scrolling" }, ] as const satisfies readonly { readonly id: string; readonly group: ScenarioGroup }[]; export const ALL_RENDER_SCENARIOS = [...RENDER_SCENARIOS, ...DIAGNOSTIC_RENDER_SCENARIOS] as const; export type ScenarioId = (typeof ALL_RENDER_SCENARIOS)[number]["id"]; + +export function scenarioDataValidity(scenarioId: ScenarioId): RenderDataValidity | undefined { + if (scenarioId === WINDOW_TRANSFER_BASELINE_SCENARIO_ID) return "product-valid"; + if (scenarioId === WINDOW_TRANSFER_UPPER_BOUND_SCENARIO_ID) return "pixel-data-invalid"; + return undefined; +} export type FailureStage = | "build" | "launch" @@ -63,6 +81,8 @@ export interface ScenarioIdentity { readonly rows: number; readonly scenarioId: ScenarioId; readonly group: ScenarioGroup; + /** Required only for the controlled visible-window transfer diagnostic pair. */ + readonly dataValidity?: RenderDataValidity; } export interface ValidationObservation { @@ -92,8 +112,61 @@ export interface MergeIndexResourceMetrics { readonly candidatesExamined: number; } +export interface WindowTransferMetrics { + /** Timed paint calls completed by the coordinator. */ + readonly logicalFrames: number; + /** Visible-window reads requested by the coordinator. */ + readonly windowReadRequests: number; + /** Requests that performed a fresh WASM window read and decode. */ + readonly logicalWindowReads: number; + /** Exact copied WASM-to-JS output payload bytes. */ + readonly copiedBytes: number; + /** Exact consuming output-accessor return allocations. */ + readonly outputAllocationEvents: number; + readonly copiedBytesPerLogicalFrame: number; + readonly outputAllocationEventsPerLogicalFrame: number; + readonly copiedBytesPerLogicalRead: number | null; + readonly outputAllocationEventsPerLogicalRead: number | null; +} + +export function createWindowTransferMetrics(input: { + readonly logicalFrames: number; + readonly windowReadRequests: number; + readonly logicalWindowReads: number; + readonly copiedBytes: number; + readonly outputAllocationEvents: number; +}): WindowTransferMetrics { + if ( + !Number.isSafeInteger(input.logicalFrames) || + input.logicalFrames <= 0 || + !Number.isSafeInteger(input.windowReadRequests) || + input.windowReadRequests < 0 || + !Number.isSafeInteger(input.logicalWindowReads) || + input.logicalWindowReads < 0 || + input.logicalWindowReads > input.windowReadRequests || + !Number.isSafeInteger(input.copiedBytes) || + input.copiedBytes < 0 || + !Number.isSafeInteger(input.outputAllocationEvents) || + input.outputAllocationEvents < 0 + ) { + throw new RangeError("invalid visible-window transfer counters"); + } + return { + ...input, + copiedBytesPerLogicalFrame: input.copiedBytes / input.logicalFrames, + outputAllocationEventsPerLogicalFrame: input.outputAllocationEvents / input.logicalFrames, + copiedBytesPerLogicalRead: + input.logicalWindowReads === 0 ? null : input.copiedBytes / input.logicalWindowReads, + outputAllocationEventsPerLogicalRead: + input.logicalWindowReads === 0 + ? null + : input.outputAllocationEvents / input.logicalWindowReads, + }; +} + export interface MeasuredSample extends AggregateSample { readonly index: number; + readonly windowTransfer?: WindowTransferMetrics; } export interface SuccessfulScenario extends ScenarioIdentity { @@ -107,6 +180,7 @@ export interface SuccessfulScenario extends ScenarioIdentity { readonly memory: MemoryDelta; readonly resources?: RenderResourceMetrics; readonly mergeResources?: MergeIndexResourceMetrics; + readonly windowTransfer?: WindowTransferMetrics; } export interface FailedScenario extends ScenarioIdentity { @@ -156,6 +230,8 @@ export interface RenderRunMetadata { readonly orderSeed: number; readonly engineOrder: readonly (readonly EngineId[])[]; readonly launchAttempts: readonly BrowserLaunchAttempt[]; + /** Present only when the controlled window-transfer diagnostic pair is configured. */ + readonly windowTransferScenarioOrder?: readonly (readonly WindowTransferScenarioId[])[]; } export interface RenderRunConfig { @@ -377,6 +453,53 @@ function parseMergeResources(value: unknown, path: string): MergeIndexResourceMe }; } +function parseWindowTransferMetrics(value: unknown, path: string): WindowTransferMetrics { + const input = record(value, path); + const counters = { + logicalFrames: integer(input.logicalFrames, `${path}.logicalFrames`, 1), + windowReadRequests: integer(input.windowReadRequests, `${path}.windowReadRequests`, 0), + logicalWindowReads: integer(input.logicalWindowReads, `${path}.logicalWindowReads`, 0), + copiedBytes: integer(input.copiedBytes, `${path}.copiedBytes`, 0), + outputAllocationEvents: integer( + input.outputAllocationEvents, + `${path}.outputAllocationEvents`, + 0, + ), + }; + const expected = createWindowTransferMetrics(counters); + const observed = { + copiedBytesPerLogicalFrame: finite( + input.copiedBytesPerLogicalFrame, + `${path}.copiedBytesPerLogicalFrame`, + 0, + ), + outputAllocationEventsPerLogicalFrame: finite( + input.outputAllocationEventsPerLogicalFrame, + `${path}.outputAllocationEventsPerLogicalFrame`, + 0, + ), + copiedBytesPerLogicalRead: nullableFinite( + input.copiedBytesPerLogicalRead, + `${path}.copiedBytesPerLogicalRead`, + ), + outputAllocationEventsPerLogicalRead: nullableFinite( + input.outputAllocationEventsPerLogicalRead, + `${path}.outputAllocationEventsPerLogicalRead`, + ), + }; + for (const field of [ + "copiedBytesPerLogicalFrame", + "outputAllocationEventsPerLogicalFrame", + "copiedBytesPerLogicalRead", + "outputAllocationEventsPerLogicalRead", + ] as const) { + if (observed[field] !== expected[field]) { + throw new TypeError(`${path}.${field} does not match its exact counters`); + } + } + return expected; +} + function parseSample(value: unknown, path: string): MeasuredSample { const input = record(value, path); const durationMs = finite(input.durationMs, `${path}.durationMs`, 0); @@ -391,6 +514,14 @@ function parseSample(value: unknown, path: string): MeasuredSample { durationMs, operationCount, perOperationMs, + ...(input.windowTransfer === undefined + ? {} + : { + windowTransfer: parseWindowTransferMetrics( + input.windowTransfer, + `${path}.windowTransfer`, + ), + }), }; } @@ -408,6 +539,22 @@ function parseIdentity(value: Record, path: string): ScenarioId if (group !== scenarioGroup(scenarioId)) { throw new TypeError(`${path}.group does not match scenario ${scenarioId}`); } + const expectedDataValidity = scenarioDataValidity(scenarioId); + const dataValidity = + value.dataValidity === undefined + ? undefined + : enumValue( + value.dataValidity, + ["product-valid", "pixel-data-invalid"], + `${path}.dataValidity`, + ); + if (dataValidity !== expectedDataValidity) { + throw new TypeError( + expectedDataValidity === undefined + ? `${path}.dataValidity is reserved for window-transfer diagnostics` + : `${path}.dataValidity must be ${expectedDataValidity} for ${scenarioId}`, + ); + } return { runId: text(value.runId, `${path}.runId`), round: integer(value.round, `${path}.round`, 1), @@ -415,6 +562,7 @@ function parseIdentity(value: Record, path: string): ScenarioId rows: integer(value.rows, `${path}.rows`, 1), scenarioId, group, + ...(dataValidity === undefined ? {} : { dataValidity }), }; } @@ -475,6 +623,74 @@ export function parseScenarioResult( } const summary = summarizeFinite(rawSamples.map((sample) => sample.perOperationMs)); const operationCount = rawSamples.reduce((total, sample) => total + sample.operationCount, 0); + const isWindowTransferDiagnostic = identity.dataValidity !== undefined; + let windowTransfer: WindowTransferMetrics | undefined; + if (isWindowTransferDiagnostic) { + if (identity.engine !== "sheetwrite") { + throw new TypeError(`${path} window-transfer diagnostics require the sheetwrite engine`); + } + const sampleMetrics = rawSamples.map((sample, index) => { + const metrics = sample.windowTransfer; + if (!metrics) { + throw new TypeError(`${path}.rawSamples[${index}].windowTransfer is required`); + } + if ( + metrics.logicalFrames !== sample.operationCount || + metrics.windowReadRequests !== metrics.logicalFrames + ) { + throw new TypeError( + `${path}.rawSamples[${index}].windowTransfer must account for every timed frame`, + ); + } + if (identity.dataValidity === "product-valid") { + if ( + metrics.logicalWindowReads !== metrics.logicalFrames || + metrics.copiedBytes <= 0 || + metrics.outputAllocationEvents <= 0 + ) { + throw new TypeError( + `${path}.rawSamples[${index}].windowTransfer is missing baseline read counters`, + ); + } + } else if ( + metrics.logicalWindowReads !== 0 || + metrics.copiedBytes !== 0 || + metrics.outputAllocationEvents !== 0 + ) { + throw new TypeError( + `${path}.rawSamples[${index}].windowTransfer upper bound performed a fresh read`, + ); + } + return metrics; + }); + const expectedWindowTransfer = createWindowTransferMetrics({ + logicalFrames: sampleMetrics.reduce((sum, metrics) => sum + metrics.logicalFrames, 0), + windowReadRequests: sampleMetrics.reduce( + (sum, metrics) => sum + metrics.windowReadRequests, + 0, + ), + logicalWindowReads: sampleMetrics.reduce( + (sum, metrics) => sum + metrics.logicalWindowReads, + 0, + ), + copiedBytes: sampleMetrics.reduce((sum, metrics) => sum + metrics.copiedBytes, 0), + outputAllocationEvents: sampleMetrics.reduce( + (sum, metrics) => sum + metrics.outputAllocationEvents, + 0, + ), + }); + windowTransfer = parseWindowTransferMetrics(input.windowTransfer, `${path}.windowTransfer`); + for (const field of Object.keys(expectedWindowTransfer) as (keyof WindowTransferMetrics)[]) { + if (windowTransfer[field] !== expectedWindowTransfer[field]) { + throw new TypeError(`${path}.windowTransfer does not match its raw samples`); + } + } + } else if ( + input.windowTransfer !== undefined || + rawSamples.some((sample) => sample.windowTransfer !== undefined) + ) { + throw new TypeError(`${path}.windowTransfer is reserved for the controlled diagnostic pair`); + } const medianMs = finite(input.medianMs, `${path}.medianMs`, 0); const p95Ms = finite(input.p95Ms, `${path}.p95Ms`, 0); const madMs = finite(input.madMs, `${path}.madMs`, 0); @@ -504,6 +720,7 @@ export function parseScenarioResult( : { mergeResources: parseMergeResources(input.mergeResources, `${path}.mergeResources`), }), + ...(windowTransfer === undefined ? {} : { windowTransfer }), }; } @@ -537,6 +754,19 @@ function parseMetadata(value: unknown, path: string): RenderRunMetadata { enumValue(engine, ENGINE_IDS, `${path}.engineOrder[${round}][${index}]`), ), ); + const windowTransferScenarioOrder = + input.windowTransferScenarioOrder === undefined + ? undefined + : array(input.windowTransferScenarioOrder, `${path}.windowTransferScenarioOrder`).map( + (entry, round) => + array(entry, `${path}.windowTransferScenarioOrder[${round}]`).map((scenario, index) => + enumValue( + scenario, + WINDOW_TRANSFER_SCENARIO_IDS, + `${path}.windowTransferScenarioOrder[${round}][${index}]`, + ), + ), + ); const metadata: RenderRunMetadata = { commit: text(input.commit, `${path}.commit`), dirty: bool(input.dirty, `${path}.dirty`), @@ -575,6 +805,7 @@ function parseMetadata(value: unknown, path: string): RenderRunMetadata { launchAttempts: array(input.launchAttempts, `${path}.launchAttempts`).map((entry, index) => parseLaunchAttempt(entry, `${path}.launchAttempts[${index}]`), ), + ...(windowTransferScenarioOrder === undefined ? {} : { windowTransferScenarioOrder }), }; if (!Number.isFinite(Date.parse(metadata.timestamp))) { throw new TypeError(`${path}.timestamp must be an ISO timestamp`); @@ -630,6 +861,74 @@ function equalStringArrays(left: readonly string[], right: readonly string[]): b return left.length === right.length && left.every((value, index) => value === right[index]); } +export interface WindowTransferComparison { + readonly rows: number; + readonly baselineRepetitionMediansMs: readonly number[]; + readonly upperBoundRepetitionMediansMs: readonly number[]; + readonly baselineMedianMs: number; + readonly upperBoundMedianMs: number; + readonly estimatedWindowTransferCostMs: number; + readonly crossVariantSpreadMs: number; + readonly maximumWithinVariantSpreadMs: number; + /** True only when the cross-variant effect exceeds both repetition-median spreads. */ + readonly resolved: boolean; +} + +export function summarizeWindowTransferComparisons( + artifact: Pick, +): WindowTransferComparison[] { + if ( + !WINDOW_TRANSFER_SCENARIO_IDS.every((scenario) => artifact.config.scenarios.includes(scenario)) + ) { + return []; + } + return artifact.config.rows.map((rows) => { + const repetitionMedians = (scenarioId: WindowTransferScenarioId): number[] => { + const matching = artifact.results + .filter( + (result): result is SuccessfulScenario => + result.rows === rows && + result.engine === "sheetwrite" && + result.scenarioId === scenarioId && + result.status === "success", + ) + .sort((left, right) => left.round - right.round); + if ( + matching.length !== artifact.metadata.rounds || + matching.some((result, index) => result.round !== index + 1) + ) { + throw new TypeError( + `window-transfer comparison for ${rows} rows requires one successful result per repetition`, + ); + } + return matching.map((result) => result.medianMs); + }; + const baselineRepetitionMediansMs = repetitionMedians(WINDOW_TRANSFER_BASELINE_SCENARIO_ID); + const upperBoundRepetitionMediansMs = repetitionMedians( + WINDOW_TRANSFER_UPPER_BOUND_SCENARIO_ID, + ); + const baselineMedianMs = summarizeFinite(baselineRepetitionMediansMs).median; + const upperBoundMedianMs = summarizeFinite(upperBoundRepetitionMediansMs).median; + const maximumWithinVariantSpreadMs = Math.max( + Math.max(...baselineRepetitionMediansMs) - Math.min(...baselineRepetitionMediansMs), + Math.max(...upperBoundRepetitionMediansMs) - Math.min(...upperBoundRepetitionMediansMs), + ); + const estimatedWindowTransferCostMs = baselineMedianMs - upperBoundMedianMs; + const crossVariantSpreadMs = Math.abs(estimatedWindowTransferCostMs); + return { + rows, + baselineRepetitionMediansMs, + upperBoundRepetitionMediansMs, + baselineMedianMs, + upperBoundMedianMs, + estimatedWindowTransferCostMs, + crossVariantSpreadMs, + maximumWithinVariantSpreadMs, + resolved: crossVariantSpreadMs > maximumWithinVariantSpreadMs, + }; + }); +} + export function parseRenderArtifact( value: unknown, options: ParseRenderOptions = {}, @@ -653,6 +952,46 @@ export function parseRenderArtifact( } } const config = parseConfig(input.config, "artifact.config"); + const configuredWindowTransferScenarios = config.scenarios.filter((scenario) => + WINDOW_TRANSFER_SCENARIO_IDS.includes(scenario as WindowTransferScenarioId), + ); + if (configuredWindowTransferScenarios.length === 0) { + if (metadata.windowTransferScenarioOrder !== undefined) { + throw new TypeError( + "artifact.metadata.windowTransferScenarioOrder is reserved for the diagnostic pair", + ); + } + } else { + if ( + configuredWindowTransferScenarios.length !== WINDOW_TRANSFER_SCENARIO_IDS.length || + !WINDOW_TRANSFER_SCENARIO_IDS.every((scenario) => + configuredWindowTransferScenarios.includes(scenario), + ) + ) { + throw new TypeError("window-transfer baseline and upper-bound scenarios must run together"); + } + if (config.engines.length !== 1 || config.engines[0] !== "sheetwrite") { + throw new TypeError("window-transfer diagnostics support only the sheetwrite engine"); + } + const expectedOrder = counterbalancedOrder( + WINDOW_TRANSFER_SCENARIO_IDS, + metadata.rounds, + WINDOW_TRANSFER_ORDER_SEED, + ); + if ( + metadata.windowTransferScenarioOrder === undefined || + metadata.windowTransferScenarioOrder.length !== expectedOrder.length || + metadata.windowTransferScenarioOrder.some( + (order, round) => + order.length !== expectedOrder[round]!.length || + order.some((scenario, index) => scenario !== expectedOrder[round]![index]), + ) + ) { + throw new TypeError( + "artifact.metadata.windowTransferScenarioOrder is not the required counterbalance", + ); + } + } for (const engine of config.engines) { for (const order of metadata.engineOrder) { if (!order.includes(engine) || order.length !== config.engines.length) { @@ -735,7 +1074,7 @@ export function parseRenderArtifact( if (!completeness.complete) { throw new TypeError("render artifact matrix is incomplete or duplicated"); } - return { + const artifact: RenderBenchmarkArtifact = { protocolVersion: RENDER_PROTOCOL_VERSION, runId, metadata, @@ -744,6 +1083,10 @@ export function parseRenderArtifact( completeness, reproductionCommands: stringArray(input.reproductionCommands, "artifact.reproductionCommands"), }; + if (completeness.successful && configuredWindowTransferScenarios.length > 0) { + summarizeWindowTransferComparisons(artifact); + } + return artifact; } export function parseRenderArtifactJson( @@ -829,7 +1172,12 @@ export function renderBenchmarkMarkdown(value: RenderBenchmarkArtifact): string if (result.status === "failed") { return `**FAILED (${result.stage})** — ${escapeMarkdown(result.errorClass)}: ${escapeMarkdown(result.message)}`; } - return `median ${formatMs(result.medianMs)} ms; p95 ${formatMs(result.p95Ms)}; MAD ${formatMs(result.madMs)}; ${result.rawSamples.length} samples / ${result.operationCount} ops`; + const transfer = result.windowTransfer; + const transferEvidence = + transfer === undefined + ? "" + : `; validity ${result.dataValidity}; copied/frame ${transfer.copiedBytesPerLogicalFrame.toFixed(3)} B; allocations/frame ${transfer.outputAllocationEventsPerLogicalFrame.toFixed(3)}; copied/read ${transfer.copiedBytesPerLogicalRead === null ? "n/a" : `${transfer.copiedBytesPerLogicalRead.toFixed(3)} B`}; allocations/read ${transfer.outputAllocationEventsPerLogicalRead === null ? "n/a" : transfer.outputAllocationEventsPerLogicalRead.toFixed(3)}`; + return `median ${formatMs(result.medianMs)} ms; p95 ${formatMs(result.p95Ms)}; MAD ${formatMs(result.madMs)}; ${result.rawSamples.length} samples / ${result.operationCount} ops${transferEvidence}`; }); const identityLabel = `r${round}-${rows}-${scenarioId}`; lines.push( @@ -839,6 +1187,30 @@ export function renderBenchmarkMarkdown(value: RenderBenchmarkArtifact): string } } + if ( + artifact.completeness.successful && + WINDOW_TRANSFER_SCENARIO_IDS.every((scenario) => artifact.config.scenarios.includes(scenario)) + ) { + lines.push( + "", + "## Visible-window transfer diagnostic", + "", + "> The reuse upper bound deliberately paints a prior decoded view. Its pixels/data are invalid and it is not a product-valid rendering result.", + "", + `Counterbalanced scenario order: ${artifact.metadata.windowTransferScenarioOrder + ?.map((order, index) => `round ${index + 1}: ${order.join(" → ")}`) + .join("; ")}`, + "", + "| rows | baseline repetition medians | upper-bound repetition medians | estimated transfer cost | cross-variant spread | maximum within-variant spread | resolution |", + "|---:|:--|:--|---:|---:|---:|:--|", + ); + for (const comparison of summarizeWindowTransferComparisons(artifact)) { + lines.push( + `| ${comparison.rows.toLocaleString("en-US")} | ${comparison.baselineRepetitionMediansMs.map(formatMs).join(", ")} ms | ${comparison.upperBoundRepetitionMediansMs.map(formatMs).join(", ")} ms | ${formatMs(comparison.estimatedWindowTransferCostMs)} ms | ${formatMs(comparison.crossVariantSpreadMs)} ms | ${formatMs(comparison.maximumWithinVariantSpreadMs)} ms | ${comparison.resolved ? "resolved" : "unresolved"} |`, + ); + } + } + lines.push("", "## Reproduce", ""); for (const command of artifact.reproductionCommands) lines.push(`- \`${command}\``); lines.push( diff --git a/bench/src/render-scenarios.ts b/bench/src/render-scenarios.ts index 6f67f617..728c32f1 100644 --- a/bench/src/render-scenarios.ts +++ b/bench/src/render-scenarios.ts @@ -3,6 +3,7 @@ import type { ColumnarDataset } from "./dataset.js"; import { logicalValueChecksum } from "./dataset.js"; import { ALL_RENDER_SCENARIOS, + createWindowTransferMetrics, type FailedScenario, type MeasuredSample, type MemoryDelta, @@ -11,8 +12,12 @@ import { type ScenarioId, type ScenarioIdentity, type ScenarioResult, + scenarioDataValidity, scenarioGroup, type ValidationObservation, + WINDOW_TRANSFER_BASELINE_SCENARIO_ID, + WINDOW_TRANSFER_UPPER_BOUND_SCENARIO_ID, + type WindowTransferMetrics, } from "./render-protocol.js"; import { summarizeFinite } from "./stats.js"; @@ -61,6 +66,16 @@ export function measureUnresizedMillionRowGeometry(): GeometryObservation { }; } +export type WindowReadDiagnosticMode = "baseline" | "reuse-decoded-view-upper-bound"; + +export interface WindowTransferCounters { + readonly logicalFrames: number; + readonly windowReadRequests: number; + readonly logicalWindowReads: number; + readonly copiedBytes: number; + readonly outputAllocationEvents: number; +} + /** Repository-owned structural surface shared by both browser engines. */ export interface RenderBenchAdapter { readonly id: "sheetwrite" | "handsontable"; @@ -96,6 +111,9 @@ export interface RenderBenchAdapter { clearMergeHeavy(): void; resetMergeResources(): void; mergeResources(): MergeIndexResourceMetrics; + setWindowReadDiagnosticMode(mode: WindowReadDiagnosticMode): void; + resetWindowTransferCounters(): void; + windowTransferCounters(): WindowTransferCounters; destroy(): void; } @@ -114,6 +132,7 @@ export class ScenarioValidationError extends Error { interface ScenarioActions { readonly setup?: () => void; + readonly measureWindowTransfer?: boolean; readonly prepare: () => void; readonly action: () => void; readonly cleanup: () => void; @@ -245,6 +264,75 @@ function scenarioActions( const lastRow = dataset.rowCount - 1; const lastCol = adapter.colCount - 1; + if ( + scenarioId === WINDOW_TRANSFER_BASELINE_SCENARIO_ID || + scenarioId === WINDOW_TRANSFER_UPPER_BOUND_SCENARIO_ID + ) { + const upperBound = scenarioId === WINDOW_TRANSFER_UPPER_BOUND_SCENARIO_ID; + const prepare = (): void => adapter.prepareScroll("top", false); + const action = (): void => adapter.scrollBy("top", SCROLL_STEP); + return { + setup: () => { + adapter.setWindowReadDiagnosticMode("baseline"); + prepare(); + adapter.setWindowReadDiagnosticMode( + upperBound ? "reuse-decoded-view-upper-bound" : "baseline", + ); + }, + measureWindowTransfer: true, + prepare, + action, + cleanup: () => {}, + validateEffect: (observations) => { + try { + prepare(); + adapter.resetWindowTransferCounters(); + action(); + const counters = adapter.windowTransferCounters(); + checkpoint( + observations, + `${scenarioId} completes one coordinator paint`, + 1, + counters.logicalFrames, + ); + checkpoint( + observations, + `${scenarioId} requests one visible window`, + 1, + counters.windowReadRequests, + ); + if (upperBound) { + checkpoint( + observations, + `${scenarioId} deliberately reuses pixel-data-invalid decoded data`, + JSON.stringify({ + logicalWindowReads: 0, + copiedBytes: 0, + outputAllocationEvents: 0, + }), + JSON.stringify({ + logicalWindowReads: counters.logicalWindowReads, + copiedBytes: counters.copiedBytes, + outputAllocationEvents: counters.outputAllocationEvents, + }), + ); + } else { + checkpoint( + observations, + `${scenarioId} accounts for a fresh decoded window`, + true, + counters.logicalWindowReads === 1 && + counters.copiedBytes > 0 && + counters.outputAllocationEvents > 0, + ); + } + } finally { + adapter.setWindowReadDiagnosticMode("baseline"); + } + }, + }; + } + if (scenarioId === "formula-dense.paint") { const prepare = (): void => adapter.prepareScroll("top", false); const action = (): void => adapter.repaint(); @@ -697,11 +785,23 @@ function scenarioActions( }; } -function aggregateSample(actions: ScenarioActions, minimumDurationMs: number): MeasuredSample { +function aggregateSample( + adapter: RenderBenchAdapter, + actions: ScenarioActions, + minimumDurationMs: number, +): MeasuredSample { let durationMs = 0; let operationCount = 0; + const transferCounters = { + logicalFrames: 0, + windowReadRequests: 0, + logicalWindowReads: 0, + copiedBytes: 0, + outputAllocationEvents: 0, + }; do { actions.prepare(); + if (actions.measureWindowTransfer) adapter.resetWindowTransferCounters(); const started = performance.now(); let operationError: unknown; try { @@ -710,6 +810,18 @@ function aggregateSample(actions: ScenarioActions, minimumDurationMs: number): M operationError = error; } const elapsed = performance.now() - started; + if (actions.measureWindowTransfer && operationError === undefined) { + try { + const observed = adapter.windowTransferCounters(); + transferCounters.logicalFrames += observed.logicalFrames; + transferCounters.windowReadRequests += observed.windowReadRequests; + transferCounters.logicalWindowReads += observed.logicalWindowReads; + transferCounters.copiedBytes += observed.copiedBytes; + transferCounters.outputAllocationEvents += observed.outputAllocationEvents; + } catch (error) { + operationError = error; + } + } try { actions.cleanup(); } catch (cleanupError) { @@ -733,6 +845,9 @@ function aggregateSample(actions: ScenarioActions, minimumDurationMs: number): M durationMs, operationCount, perOperationMs: durationMs / operationCount, + ...(actions.measureWindowTransfer + ? { windowTransfer: createWindowTransferMetrics(transferCounters) } + : {}), }; } @@ -770,6 +885,7 @@ export function runRenderScenario( if (!ALL_RENDER_SCENARIOS.some((scenario) => scenario.id === scenarioId)) { throw new RangeError(`unknown render scenario: ${scenarioId}`); } + const dataValidity = scenarioDataValidity(scenarioId); const identity: ScenarioIdentity = { runId: options.runId, round: options.round, @@ -777,6 +893,7 @@ export function runRenderScenario( rows: dataset.rowCount, scenarioId, group: scenarioGroup(scenarioId), + ...(dataValidity === undefined ? {} : { dataValidity }), }; const actions = scenarioActions(scenarioId, adapter, dataset); const validation: ValidationObservation[] = []; @@ -791,12 +908,15 @@ export function runRenderScenario( stage = "warmup"; options.onStage?.("warmup"); for (let index = 0; index < options.warmupSamples; index++) { - aggregateSample(actions, options.minimumSampleDurationMs); + aggregateSample(adapter, actions, options.minimumSampleDurationMs); } stage = "measure"; options.onStage?.("measure"); for (let index = 0; index < options.measuredSamples; index++) { - rawSamples.push({ ...aggregateSample(actions, options.minimumSampleDurationMs), index }); + rawSamples.push({ + ...aggregateSample(adapter, actions, options.minimumSampleDurationMs), + index, + }); } stage = "validate"; options.onStage?.("validate"); @@ -804,6 +924,25 @@ export function runRenderScenario( validateCanonicalState(adapter, dataset, validation); const afterBytes = usedJsHeapBytes(); const summary = summarizeFinite(rawSamples.map((sample) => sample.perOperationMs)); + let windowTransfer: WindowTransferMetrics | undefined; + if (actions.measureWindowTransfer) { + const metrics = rawSamples.map((sample) => { + if (!sample.windowTransfer) { + throw new ScenarioValidationError("window-transfer sample counters are missing"); + } + return sample.windowTransfer; + }); + windowTransfer = createWindowTransferMetrics({ + logicalFrames: metrics.reduce((sum, entry) => sum + entry.logicalFrames, 0), + windowReadRequests: metrics.reduce((sum, entry) => sum + entry.windowReadRequests, 0), + logicalWindowReads: metrics.reduce((sum, entry) => sum + entry.logicalWindowReads, 0), + copiedBytes: metrics.reduce((sum, entry) => sum + entry.copiedBytes, 0), + outputAllocationEvents: metrics.reduce( + (sum, entry) => sum + entry.outputAllocationEvents, + 0, + ), + }); + } return { ...identity, status: "success", @@ -816,6 +955,7 @@ export function runRenderScenario( memory: memoryDelta(beforeBytes, afterBytes), resources: adapter.formatResources(), mergeResources: adapter.mergeResources(), + ...(windowTransfer === undefined ? {} : { windowTransfer }), }; } catch (error) { return failedScenario( diff --git a/bench/src/wasm-profile-matrix.ts b/bench/src/wasm-profile-matrix.ts new file mode 100644 index 00000000..23fe51e1 --- /dev/null +++ b/bench/src/wasm-profile-matrix.ts @@ -0,0 +1,483 @@ +export const WASM_PROFILE_MATRIX_SCHEMA_VERSION = 1 as const; +export const WASM_PROFILE_MATRIX_PROTOCOL = "wasm-post-link-profile-matrix-v1" as const; +export const WASM_OPT_PROFILES = ["-O3", "-Os", "-Oz"] as const; +export type WasmOptProfile = (typeof WASM_OPT_PROFILES)[number]; + +const SHA256 = /^[0-9a-f]{64}$/u; +const COMMIT = /^[0-9a-f]{40}$/u; +const STORE_ONLY_COMMAND = [ + "cargo", + "check", + "--target", + "wasm32-unknown-unknown", + "--no-default-features", + "--message-format=json", +] as const; +const BLOCKER_IDS = ["formula-entry-ast", "dependency-index"] as const; + +type GateStatus = "passed" | "failed"; + +export interface MatrixGateEvidence { + readonly status: GateStatus; + readonly commands: readonly (readonly string[])[]; + readonly exitCode: number; + readonly artifactPath: string; + readonly evidenceSha256: string; + readonly wasmSha256: string; + readonly commit: string; + readonly runnerFingerprint: string; +} + +export interface WasmProfileVariant { + readonly wasmOpt: WasmOptProfile; + readonly inputWasmSha256: string; + readonly artifact: { + readonly rawBytes: number; + readonly gzipBytes: number; + readonly brotliBytes: number; + readonly sha256: string; + }; + readonly formulaGate: MatrixGateEvidence; + readonly renderGate: MatrixGateEvidence; +} + +export interface StoreOnlyBlocker { + readonly id: (typeof BLOCKER_IDS)[number]; + readonly kind: "dependency-cycle"; + readonly owner: string; + readonly dependency: string; + readonly reason: string; +} + +export interface StoreOnlyProbe { + readonly provenance: { + readonly commit: string; + readonly dirty: false; + readonly timestamp: string; + }; + readonly command: readonly string[]; + readonly outcome: "compiled" | "blocked" | "failed"; + readonly exitCode: number; + readonly stdoutSha256: string; + readonly stderrSha256: string; + readonly blockers: readonly StoreOnlyBlocker[]; +} + +export interface WasmProfileMatrixEvidence { + readonly schemaVersion: typeof WASM_PROFILE_MATRIX_SCHEMA_VERSION; + readonly protocol: typeof WASM_PROFILE_MATRIX_PROTOCOL; + readonly provenance: { + readonly commit: string; + readonly dirty: false; + readonly timestamp: string; + readonly host: string; + readonly rustc: string; + readonly cargo: string; + readonly wasmPack: string; + readonly wasmOptVersion: string; + readonly wasmOptSha256: string; + readonly bun: string; + }; + readonly rustProfile: { + readonly optLevel: 3; + readonly lto: "fat"; + readonly codegenUnits: 1; + }; + readonly postLink: { + readonly inputWasmSha256: string; + readonly compression: { + readonly gzipLevel: 9; + readonly brotliQuality: 11; + readonly brotliMode: "generic"; + }; + }; + readonly decisionPolicy: { + readonly sizeMetric: "brotliBytes"; + readonly requireFormulaGate: true; + readonly requireRenderGate: true; + }; + readonly captureOrder: { + readonly formula: readonly WasmOptProfile[]; + readonly render: readonly WasmOptProfile[]; + }; + readonly variants: readonly WasmProfileVariant[]; + readonly storeOnlyProbe: StoreOnlyProbe; +} + +export type WasmProfileDecision = + | { + readonly status: "recommended"; + readonly wasmOpt: WasmOptProfile; + readonly baseline: "-O3"; + readonly savedBytes: { readonly raw: number; readonly gzip: number; readonly brotli: number }; + } + | { readonly status: "blocked"; readonly failures: readonly string[] }; + +function record(value: unknown, label: string): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error(`${label} must be an object`); + } + return value as Record; +} + +function string(value: unknown, label: string): string { + if (typeof value !== "string" || value.length === 0) throw new Error(`${label} must be a string`); + return value; +} + +function integer(value: unknown, label: string, minimum = 0): number { + if (!Number.isInteger(value) || (value as number) < minimum) { + throw new Error(`${label} must be an integer >= ${minimum}`); + } + return value as number; +} + +function literal( + value: unknown, + expected: T, + label: string, +): T { + if (value !== expected) throw new Error(`${label} must be ${JSON.stringify(expected)}`); + return expected; +} + +function hash(value: unknown, label: string): string { + const parsed = string(value, label); + if (!SHA256.test(parsed)) throw new Error(`${label} must be a lowercase SHA-256`); + return parsed; +} + +function commit(value: unknown, label: string): string { + const parsed = string(value, label); + if (!COMMIT.test(parsed)) throw new Error(`${label} must be a full lowercase commit hash`); + return parsed; +} + +function stringArray(value: unknown, label: string): string[] { + if (!Array.isArray(value) || value.length === 0) + throw new Error(`${label} must be a non-empty array`); + return value.map((entry, index) => string(entry, `${label}[${index}]`)); +} + +function canonicalTimestamp(value: unknown, label: string): string { + const parsed = string(value, label); + const milliseconds = Date.parse(parsed); + if (!Number.isFinite(milliseconds) || new Date(milliseconds).toISOString() !== parsed) { + throw new Error(`${label} must be a canonical ISO timestamp`); + } + return parsed; +} + +export function assertCleanMatrixCapture(status: string): void { + if (status.trim().length > 0) { + throw new Error("controlled matrix capture requires a completely clean tree"); + } +} + +function profile(value: unknown, label: string): WasmOptProfile { + if (!WASM_OPT_PROFILES.includes(value as WasmOptProfile)) { + throw new Error(`${label} must be one of ${WASM_OPT_PROFILES.join(", ")}`); + } + return value as WasmOptProfile; +} + +function exactProfiles(value: unknown, label: string): WasmOptProfile[] { + if (!Array.isArray(value)) throw new Error(`${label} must be an array`); + const parsed = value.map((entry, index) => profile(entry, `${label}[${index}]`)); + if ( + parsed.length !== WASM_OPT_PROFILES.length || + new Set(parsed).size !== WASM_OPT_PROFILES.length + ) { + throw new Error(`${label} must contain -O3, -Os, and -Oz exactly once`); + } + return parsed; +} + +function commandList(value: unknown, label: string): string[][] { + if (!Array.isArray(value) || value.length === 0) { + throw new Error(`${label} must be a non-empty array`); + } + return value.map((entry, index) => stringArray(entry, `${label}[${index}]`)); +} + +function parseGate( + value: unknown, + label: string, + kind: "formula" | "render", + expectedCommit: string, + expectedWasmSha256: string, +): MatrixGateEvidence { + const gate = record(value, label); + const status = gate.status; + if (status !== "passed" && status !== "failed") { + throw new Error(`${label}.status must be passed or failed`); + } + const exitCode = integer(gate.exitCode, `${label}.exitCode`); + if ((status === "passed") !== (exitCode === 0)) { + throw new Error(`${label} status and exitCode disagree`); + } + const parsed: MatrixGateEvidence = { + status, + commands: commandList(gate.commands, `${label}.commands`), + exitCode, + artifactPath: string(gate.artifactPath, `${label}.artifactPath`), + evidenceSha256: hash(gate.evidenceSha256, `${label}.evidenceSha256`), + wasmSha256: hash(gate.wasmSha256, `${label}.wasmSha256`), + commit: commit(gate.commit, `${label}.commit`), + runnerFingerprint: hash(gate.runnerFingerprint, `${label}.runnerFingerprint`), + }; + const runsFormula = parsed.commands.some( + (command) => + command.includes("bench:formula") || + command.some((token) => token.endsWith("/formula-bench.ts") || token === "formula-bench.ts"), + ); + const runsRender = parsed.commands.some( + (command) => + command.includes("bench:render") || + command.some((token) => token.endsWith("/render-driver.ts") || token === "render-driver.ts"), + ); + const runsRenderCheck = parsed.commands.some( + (command) => + command.includes("bench:check") || + command.some((token) => token.endsWith("/check.ts") || token === "check.ts"), + ); + const commandLines = parsed.commands.map((command) => command.join(" ")); + if (kind === "formula") { + if ( + !runsFormula || + commandLines.some((command) => /(?:--preliminary|--smoke|bench:formula:smoke)/u.test(command)) + ) { + throw new Error(`${label} must run the gating formula benchmark`); + } + } else if ( + !runsRender || + !runsRenderCheck || + commandLines.some((command) => + /(?:--report-only|--smoke|--scenario-set\s+diagnostic|bench:render:(?:smoke|diagnostic))/u.test( + command, + ), + ) + ) { + throw new Error(`${label} must run the render capture and fail-closed check`); + } + if (parsed.commit !== expectedCommit) throw new Error(`${label} commit is incomparable`); + if (parsed.wasmSha256 !== expectedWasmSha256) { + throw new Error(`${label} was not run against its variant artifact`); + } + return parsed; +} + +function parseStoreOnlyProbe(value: unknown, expectedCommit: string): StoreOnlyProbe { + const probe = record(value, "storeOnlyProbe"); + const provenanceRaw = record(probe.provenance, "storeOnlyProbe.provenance"); + const provenance = { + commit: commit(provenanceRaw.commit, "storeOnlyProbe.provenance.commit"), + dirty: literal(provenanceRaw.dirty, false, "storeOnlyProbe.provenance.dirty"), + timestamp: canonicalTimestamp(provenanceRaw.timestamp, "storeOnlyProbe.provenance.timestamp"), + } as const; + if (provenance.commit !== expectedCommit) { + throw new Error("storeOnlyProbe commit does not match matrix provenance"); + } + const command = stringArray(probe.command, "storeOnlyProbe.command"); + if (JSON.stringify(command) !== JSON.stringify(STORE_ONLY_COMMAND)) { + throw new Error("storeOnlyProbe.command must be the controlled --no-default-features check"); + } + const outcome = probe.outcome; + if (outcome !== "compiled" && outcome !== "blocked" && outcome !== "failed") { + throw new Error("storeOnlyProbe.outcome must be compiled, blocked, or failed"); + } + const exitCode = integer(probe.exitCode, "storeOnlyProbe.exitCode"); + if ((outcome === "compiled") !== (exitCode === 0)) { + throw new Error("storeOnlyProbe outcome and exitCode disagree"); + } + if (!Array.isArray(probe.blockers)) throw new Error("storeOnlyProbe.blockers must be an array"); + const blockers = probe.blockers.map((raw, index): StoreOnlyBlocker => { + const blocker = record(raw, `storeOnlyProbe.blockers[${index}]`); + const id = blocker.id; + if (!BLOCKER_IDS.includes(id as StoreOnlyBlocker["id"])) { + throw new Error(`storeOnlyProbe.blockers[${index}].id is unknown`); + } + return { + id: id as StoreOnlyBlocker["id"], + kind: literal(blocker.kind, "dependency-cycle", `storeOnlyProbe.blockers[${index}].kind`), + owner: string(blocker.owner, `storeOnlyProbe.blockers[${index}].owner`), + dependency: string(blocker.dependency, `storeOnlyProbe.blockers[${index}].dependency`), + reason: string(blocker.reason, `storeOnlyProbe.blockers[${index}].reason`), + }; + }); + if (outcome === "blocked") { + if ( + blockers.length !== BLOCKER_IDS.length || + new Set(blockers.map(({ id }) => id)).size !== BLOCKER_IDS.length + ) { + throw new Error("blocked store-only probe must report both dependency-cycle blockers"); + } + } else if (blockers.length > 0) { + throw new Error(`${outcome} store-only probe cannot claim dependency-cycle blockers`); + } + return { + provenance, + command, + outcome, + exitCode, + stdoutSha256: hash(probe.stdoutSha256, "storeOnlyProbe.stdoutSha256"), + stderrSha256: hash(probe.stderrSha256, "storeOnlyProbe.stderrSha256"), + blockers, + }; +} + +export function parseWasmProfileMatrix(value: unknown): WasmProfileMatrixEvidence { + const root = record(value, "matrix"); + literal(root.schemaVersion, WASM_PROFILE_MATRIX_SCHEMA_VERSION, "schemaVersion"); + literal(root.protocol, WASM_PROFILE_MATRIX_PROTOCOL, "protocol"); + const provenanceRaw = record(root.provenance, "provenance"); + const provenance = { + commit: commit(provenanceRaw.commit, "provenance.commit"), + dirty: literal(provenanceRaw.dirty, false, "provenance.dirty"), + timestamp: string(provenanceRaw.timestamp, "provenance.timestamp"), + host: string(provenanceRaw.host, "provenance.host"), + rustc: string(provenanceRaw.rustc, "provenance.rustc"), + cargo: string(provenanceRaw.cargo, "provenance.cargo"), + wasmPack: string(provenanceRaw.wasmPack, "provenance.wasmPack"), + wasmOptVersion: string(provenanceRaw.wasmOptVersion, "provenance.wasmOptVersion"), + wasmOptSha256: hash(provenanceRaw.wasmOptSha256, "provenance.wasmOptSha256"), + bun: string(provenanceRaw.bun, "provenance.bun"), + } as const; + const provenanceTimestamp = canonicalTimestamp(provenance.timestamp, "provenance.timestamp"); + const normalizedProvenance = { ...provenance, timestamp: provenanceTimestamp }; + const rustRaw = record(root.rustProfile, "rustProfile"); + const rustProfile = { + optLevel: literal(rustRaw.optLevel, 3, "rustProfile.optLevel"), + lto: literal(rustRaw.lto, "fat", "rustProfile.lto"), + codegenUnits: literal(rustRaw.codegenUnits, 1, "rustProfile.codegenUnits"), + } as const; + const postLinkRaw = record(root.postLink, "postLink"); + const compressionRaw = record(postLinkRaw.compression, "postLink.compression"); + const postLink = { + inputWasmSha256: hash(postLinkRaw.inputWasmSha256, "postLink.inputWasmSha256"), + compression: { + gzipLevel: literal(compressionRaw.gzipLevel, 9, "postLink.compression.gzipLevel"), + brotliQuality: literal( + compressionRaw.brotliQuality, + 11, + "postLink.compression.brotliQuality", + ), + brotliMode: literal(compressionRaw.brotliMode, "generic", "postLink.compression.brotliMode"), + }, + } as const; + const policyRaw = record(root.decisionPolicy, "decisionPolicy"); + const decisionPolicy = { + sizeMetric: literal(policyRaw.sizeMetric, "brotliBytes", "decisionPolicy.sizeMetric"), + requireFormulaGate: literal( + policyRaw.requireFormulaGate, + true, + "decisionPolicy.requireFormulaGate", + ), + requireRenderGate: literal( + policyRaw.requireRenderGate, + true, + "decisionPolicy.requireRenderGate", + ), + } as const; + const orderRaw = record(root.captureOrder, "captureOrder"); + const captureOrder = { + formula: exactProfiles(orderRaw.formula, "captureOrder.formula"), + render: exactProfiles(orderRaw.render, "captureOrder.render"), + }; + + if (!Array.isArray(root.variants)) throw new Error("variants must be an array"); + const modes = new Set(); + const variants = root.variants.map((raw, index): WasmProfileVariant => { + const variant = record(raw, `variants[${index}]`); + const wasmOpt = profile(variant.wasmOpt, `variants[${index}].wasmOpt`); + if (modes.has(wasmOpt)) throw new Error(`duplicate ${wasmOpt} variant`); + modes.add(wasmOpt); + const inputWasmSha256 = hash(variant.inputWasmSha256, `variants[${index}].inputWasmSha256`); + if (inputWasmSha256 !== postLink.inputWasmSha256) { + throw new Error(`${wasmOpt} did not use the shared pre-post-link artifact`); + } + const artifactRaw = record(variant.artifact, `variants[${index}].artifact`); + const artifact = { + rawBytes: integer(artifactRaw.rawBytes, `variants[${index}].artifact.rawBytes`, 1), + gzipBytes: integer(artifactRaw.gzipBytes, `variants[${index}].artifact.gzipBytes`, 1), + brotliBytes: integer(artifactRaw.brotliBytes, `variants[${index}].artifact.brotliBytes`, 1), + sha256: hash(artifactRaw.sha256, `variants[${index}].artifact.sha256`), + }; + if (artifact.gzipBytes >= artifact.rawBytes || artifact.brotliBytes >= artifact.rawBytes) { + throw new Error(`${wasmOpt} compressed sizes must be smaller than raw size`); + } + return { + wasmOpt, + inputWasmSha256, + artifact, + formulaGate: parseGate( + variant.formulaGate, + `variants[${index}].formulaGate`, + "formula", + provenance.commit, + artifact.sha256, + ), + renderGate: parseGate( + variant.renderGate, + `variants[${index}].renderGate`, + "render", + provenance.commit, + artifact.sha256, + ), + }; + }); + if (variants.length !== WASM_OPT_PROFILES.length || modes.size !== WASM_OPT_PROFILES.length) { + throw new Error("variants must contain -O3, -Os, and -Oz exactly once"); + } + for (const gate of ["formulaGate", "renderGate"] as const) { + const fingerprints = new Set(variants.map((variant) => variant[gate].runnerFingerprint)); + const evidenceHashes = new Set(variants.map((variant) => variant[gate].evidenceSha256)); + const artifactPaths = new Set(variants.map((variant) => variant[gate].artifactPath)); + if (evidenceHashes.size !== variants.length || artifactPaths.size !== variants.length) { + throw new Error(`${gate} evidence was reused across variants`); + } + if (fingerprints.size !== 1) throw new Error(`${gate} runner provenance is incomparable`); + } + + return { + schemaVersion: WASM_PROFILE_MATRIX_SCHEMA_VERSION, + protocol: WASM_PROFILE_MATRIX_PROTOCOL, + provenance: normalizedProvenance, + rustProfile, + postLink, + decisionPolicy, + captureOrder, + variants, + storeOnlyProbe: parseStoreOnlyProbe(root.storeOnlyProbe, normalizedProvenance.commit), + }; +} + +export function decideWasmProfile(value: unknown): WasmProfileDecision { + const evidence = parseWasmProfileMatrix(value); + const failures: string[] = []; + for (const variant of evidence.variants) { + if (variant.formulaGate.status !== "passed") + failures.push(`${variant.wasmOpt} formula gate failed`); + if (variant.renderGate.status !== "passed") + failures.push(`${variant.wasmOpt} render gate failed`); + } + if (evidence.storeOnlyProbe.outcome === "failed") { + failures.push("store-only compile probe failed without a classified outcome"); + } + if (failures.length > 0) return { status: "blocked", failures }; + + const baseline = evidence.variants.find(({ wasmOpt }) => wasmOpt === "-O3")!; + const selected = evidence.variants.reduce((best, candidate) => + candidate.artifact.brotliBytes < best.artifact.brotliBytes ? candidate : best, + ); + return { + status: "recommended", + wasmOpt: selected.wasmOpt, + baseline: "-O3", + savedBytes: { + raw: baseline.artifact.rawBytes - selected.artifact.rawBytes, + gzip: baseline.artifact.gzipBytes - selected.artifact.gzipBytes, + brotli: baseline.artifact.brotliBytes - selected.artifact.brotliBytes, + }, + }; +} diff --git a/bench/test/render-driver.test.ts b/bench/test/render-driver.test.ts index f5862e8a..4800eb74 100644 --- a/bench/test/render-driver.test.ts +++ b/bench/test/render-driver.test.ts @@ -1,6 +1,10 @@ import { describe, expect, test } from "bun:test"; import { createCombinationFailures } from "../src/render-driver.js"; -import { DIAGNOSTIC_RENDER_SCENARIOS, RENDER_SCENARIOS } from "../src/render-protocol.js"; +import { + DIAGNOSTIC_RENDER_SCENARIOS, + RENDER_SCENARIOS, + WINDOW_TRANSFER_UPPER_BOUND_SCENARIO_ID, +} from "../src/render-protocol.js"; const configuration = { runId: "launch-fixture", @@ -36,6 +40,10 @@ describe("isolated browser failure envelopes", () => { new Error("browser executable does not exist"), ); expect(results.map((result) => result.scenarioId)).toEqual(scenarios); + expect( + results.find((result) => result.scenarioId === WINDOW_TRANSFER_UPPER_BOUND_SCENARIO_ID) + ?.dataValidity, + ).toBe("pixel-data-invalid"); }); test("timeouts retain timeout/crash diagnostics in every cell", () => { diff --git a/bench/test/render-protocol.test.ts b/bench/test/render-protocol.test.ts index 3de2e65f..f09eb6d1 100644 --- a/bench/test/render-protocol.test.ts +++ b/bench/test/render-protocol.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { + createWindowTransferMetrics, type FailedScenario, parseRenderArtifact, parseRenderArtifactJson, @@ -11,7 +12,13 @@ import { type ScenarioResult, scenarioGroup, summarizeCompleteness, + summarizeWindowTransferComparisons, + WINDOW_TRANSFER_BASELINE_SCENARIO_ID, + WINDOW_TRANSFER_ORDER_SEED, + WINDOW_TRANSFER_SCENARIO_IDS, + WINDOW_TRANSFER_UPPER_BOUND_SCENARIO_ID, } from "../src/render-protocol.js"; +import { counterbalancedOrder } from "../src/stats.js"; const RUN_ID = "00000000-0000-4000-8000-000000000051"; const TIMESTAMP = "2026-07-14T12:00:00.000Z"; @@ -158,6 +165,109 @@ function completeArtifact(): RenderBenchmarkArtifact { return artifactWithResults(results); } +function windowTransferArtifact(): RenderBenchmarkArtifact { + const config: RenderRunConfig = { + engines: ["sheetwrite"], + rows: [200], + scenarios: [...WINDOW_TRANSFER_SCENARIO_IDS], + }; + const results: ScenarioResult[] = []; + for (let round = 1; round <= 2; round++) { + for (const scenarioId of WINDOW_TRANSFER_SCENARIO_IDS) { + const baseline = scenarioId === WINDOW_TRANSFER_BASELINE_SCENARIO_ID; + const perOperationMs = baseline ? (round === 1 ? 5 : 5.2) : round === 1 ? 3 : 3.1; + const sampleCounters = { + logicalFrames: 100, + windowReadRequests: 100, + logicalWindowReads: baseline ? 100 : 0, + copiedBytes: baseline ? 102_400 : 0, + outputAllocationEvents: baseline ? 700 : 0, + }; + const rawSamples = [0, 1].map((index) => ({ + index, + durationMs: perOperationMs * 100, + operationCount: 100, + perOperationMs, + windowTransfer: createWindowTransferMetrics(sampleCounters), + })); + results.push({ + runId: RUN_ID, + round, + engine: "sheetwrite", + rows: 200, + scenarioId, + group: scenarioGroup(scenarioId), + dataValidity: baseline ? "product-valid" : "pixel-data-invalid", + status: "success", + operationCount: 200, + rawSamples, + medianMs: perOperationMs, + p95Ms: perOperationMs, + madMs: 0, + validation: [ + { + checkpoint: "controlled window transfer", + expected: "accounted", + observed: "accounted", + passed: true, + }, + ], + memory: { beforeBytes: null, afterBytes: null, deltaBytes: null }, + windowTransfer: createWindowTransferMetrics({ + logicalFrames: 200, + windowReadRequests: 200, + logicalWindowReads: baseline ? 200 : 0, + copiedBytes: baseline ? 204_800 : 0, + outputAllocationEvents: baseline ? 1_400 : 0, + }), + }); + } + } + return { + protocolVersion: RENDER_PROTOCOL_VERSION, + runId: RUN_ID, + metadata: { + commit: "4ba3902", + dirty: false, + timestamp: TIMESTAMP, + bunVersion: "1.3.14", + nodeVersion: "24.3.0", + browserVersion: "Chromium 140", + os: "linux 6.0", + arch: "x64", + cpu: "test cpu", + engineVersions: { sheetwrite: "0.1.0", handsontable: "18.0.0" }, + datasetSeed: 0x5eedc0de, + datasetHashes: { "200": "fnv1a32:12345678" }, + viewport: { width: 640, height: 480 }, + measuredSamples: 2, + warmupSamples: 1, + minimumSampleDurationMs: 100, + rounds: 2, + orderSeed: 0x51c0ffee, + engineOrder: [["sheetwrite"], ["sheetwrite"]], + launchAttempts: [1, 2].map((round) => ({ + round, + engine: "sheetwrite" as const, + rows: 200, + attempt: 1, + success: true, + errorClass: null, + message: null, + })), + windowTransferScenarioOrder: counterbalancedOrder( + WINDOW_TRANSFER_SCENARIO_IDS, + 2, + WINDOW_TRANSFER_ORDER_SEED, + ), + }, + config, + results, + completeness: summarizeCompleteness(config, 2, results), + reproductionCommands: ["bun run --filter '@sheetwrite/bench' bench:render:diagnostic"], + }; +} + describe("render artifact validation", () => { test("accepts a complete finite matrix", () => { const parsed = parseRenderArtifactJson(JSON.stringify(completeArtifact()), { @@ -237,6 +347,40 @@ describe("render artifact validation", () => { expect(() => parseRenderArtifact(invalid)).toThrow("finite number"); expect(() => parseRenderArtifactJson(JSON.stringify(invalid))).toThrow("finite number"); }); + test("fails closed on transfer counters, invalid labels, and order drift", () => { + const parsed = parseRenderArtifact(windowTransferArtifact()); + const [comparison] = summarizeWindowTransferComparisons(parsed); + expect(comparison).toMatchObject({ rows: 200, resolved: true }); + expect(comparison?.estimatedWindowTransferCostMs).toBeCloseTo(2.05); + expect(comparison?.maximumWithinVariantSpreadMs).toBeCloseTo(0.2); + + const missingCounters = structuredClone(windowTransferArtifact()); + const baseline = missingCounters.results.find( + (result) => result.scenarioId === WINDOW_TRANSFER_BASELINE_SCENARIO_ID, + ); + if (baseline?.status !== "success") throw new Error("missing baseline fixture"); + Reflect.deleteProperty(baseline.rawSamples[0]!, "windowTransfer"); + expect(() => parseRenderArtifact(missingCounters)).toThrow( + "rawSamples[0].windowTransfer is required", + ); + + const falselyValid = structuredClone(windowTransferArtifact()); + const upperBound = falselyValid.results.find( + (result) => result.scenarioId === WINDOW_TRANSFER_UPPER_BOUND_SCENARIO_ID, + ); + if (!upperBound) throw new Error("missing upper-bound fixture"); + Reflect.set(upperBound, "dataValidity", "product-valid"); + expect(() => parseRenderArtifact(falselyValid)).toThrow("must be pixel-data-invalid"); + + const wrongOrder = structuredClone(windowTransferArtifact()); + const firstOrder = wrongOrder.metadata.windowTransferScenarioOrder?.[0]; + if (!firstOrder) throw new Error("missing counterbalance fixture"); + Reflect.set(wrongOrder.metadata, "windowTransferScenarioOrder", [ + [...firstOrder].reverse(), + [...firstOrder].reverse(), + ]); + expect(() => parseRenderArtifact(wrongOrder)).toThrow("required counterbalance"); + }); }); describe("derived Markdown evidence", () => { @@ -250,4 +394,11 @@ describe("derived Markdown evidence", () => { expect(markdown).toContain("(./render-results.json)"); expect(markdown).toContain("Comparative headline ratios are intentionally omitted"); }); + test("labels the stale-view comparison and reports exact per-frame/read counters", () => { + const markdown = renderBenchmarkMarkdown(windowTransferArtifact()); + expect(markdown).toContain("pixels/data are invalid"); + expect(markdown).toContain("copied/frame"); + expect(markdown).toContain("allocations/read"); + expect(markdown).toContain("maximum within-variant spread"); + }); }); diff --git a/bench/test/render-scenarios.test.ts b/bench/test/render-scenarios.test.ts index a0e467cc..44415942 100644 --- a/bench/test/render-scenarios.test.ts +++ b/bench/test/render-scenarios.test.ts @@ -1,6 +1,10 @@ import { describe, expect, test } from "bun:test"; import { makeColumnar, toAoA } from "../src/dataset.js"; -import { DIAGNOSTIC_RENDER_SCENARIOS } from "../src/render-protocol.js"; +import { + DIAGNOSTIC_RENDER_SCENARIOS, + WINDOW_TRANSFER_BASELINE_SCENARIO_ID, + WINDOW_TRANSFER_UPPER_BOUND_SCENARIO_ID, +} from "../src/render-protocol.js"; import { type CellSelection, measureUnresizedMillionRowGeometry, @@ -28,6 +32,14 @@ class FakeAdapter implements RenderBenchAdapter { private top = 0; private left = 0; private readonly evidence: FakeEvidence; + private windowReadMode: "baseline" | "reuse-decoded-view-upper-bound" = "baseline"; + private transferCounters = { + logicalFrames: 0, + windowReadRequests: 0, + logicalWindowReads: 0, + copiedBytes: 0, + outputAllocationEvents: 0, + }; constructor(rows = 200, evidence: FakeEvidence = {}) { const dataset = makeColumnar(rows); @@ -75,8 +87,14 @@ class FakeAdapter implements RenderBenchAdapter { scrollBy(axis: "top" | "left", pixels: number): void { if (axis === "top") this.top = Math.min(1_000, this.top + pixels); else this.left = Math.min(500, this.left + pixels); + this.transferCounters.logicalFrames++; + this.transferCounters.windowReadRequests++; + if (this.windowReadMode === "baseline") { + this.transferCounters.logicalWindowReads++; + this.transferCounters.copiedBytes += 1_024; + this.transferCounters.outputAllocationEvents += 7; + } } - scrollObservation(): ScrollObservation { return { top: this.top, @@ -200,6 +218,22 @@ class FakeAdapter implements RenderBenchAdapter { }; } + setWindowReadDiagnosticMode(mode: "baseline" | "reuse-decoded-view-upper-bound"): void { + this.windowReadMode = mode; + } + + resetWindowTransferCounters(): void { + this.transferCounters.logicalFrames = 0; + this.transferCounters.windowReadRequests = 0; + this.transferCounters.logicalWindowReads = 0; + this.transferCounters.copiedBytes = 0; + this.transferCounters.outputAllocationEvents = 0; + } + + windowTransferCounters() { + return { ...this.transferCounters }; + } + destroy(): void { this.mounted = false; } @@ -290,6 +324,42 @@ describe("scenario correctness checkpoints", () => { expect(result.validation.every((observation) => observation.passed)).toBe(true); } }); + test("separates exact transfer counters from the pixel-data-invalid upper bound", () => { + const baseline = runRenderScenario( + new FakeAdapter(), + dataset, + WINDOW_TRANSFER_BASELINE_SCENARIO_ID, + options, + ); + const upperBound = runRenderScenario( + new FakeAdapter(), + dataset, + WINDOW_TRANSFER_UPPER_BOUND_SCENARIO_ID, + options, + ); + expect(baseline.status).toBe("success"); + expect(upperBound.status).toBe("success"); + if (baseline.status !== "success" || upperBound.status !== "success") { + throw new Error("expected successful transfer diagnostic pair"); + } + expect(baseline.dataValidity).toBe("product-valid"); + expect(baseline.windowTransfer).toMatchObject({ + copiedBytesPerLogicalFrame: 1_024, + outputAllocationEventsPerLogicalFrame: 7, + copiedBytesPerLogicalRead: 1_024, + outputAllocationEventsPerLogicalRead: 7, + }); + expect(upperBound.dataValidity).toBe("pixel-data-invalid"); + expect(upperBound.windowTransfer).toMatchObject({ + logicalWindowReads: 0, + copiedBytes: 0, + outputAllocationEvents: 0, + copiedBytesPerLogicalRead: null, + outputAllocationEventsPerLogicalRead: null, + }); + expect(upperBound.windowTransfer?.logicalFrames).toBe(upperBound.operationCount); + expect(upperBound.windowTransfer?.windowReadRequests).toBe(upperBound.operationCount); + }); test("rejects corrupt formatter and merge-index evidence", () => { for (const [adapter, scenario, checkpoint] of [ diff --git a/bench/test/wasm-profile-matrix.test.ts b/bench/test/wasm-profile-matrix.test.ts new file mode 100644 index 00000000..6e2f3e37 --- /dev/null +++ b/bench/test/wasm-profile-matrix.test.ts @@ -0,0 +1,279 @@ +import { describe, expect, it } from "bun:test"; +import { + assertCleanMatrixCapture, + decideWasmProfile, + parseWasmProfileMatrix, +} from "../src/wasm-profile-matrix.js"; + +const HASH = { + input: "a".repeat(64), + runner: "b".repeat(64), + tool: "c".repeat(64), + evidence: "d".repeat(64), + stdout: "e".repeat(64), + stderr: "f".repeat(64), +} as const; +const COMMIT = "1".repeat(40); + +function gate(wasmSha256: string, kind: "formula" | "render") { + return { + status: "passed", + commands: + kind === "formula" + ? [["bun", "run", "bench:formula"]] + : [ + ["bun", "run", "bench:render"], + ["bun", "run", "bench:check"], + ], + exitCode: 0, + artifactPath: `bench/results/candidate-${wasmSha256.slice(0, 8)}.json`, + evidenceSha256: wasmSha256, + wasmSha256, + commit: COMMIT, + runnerFingerprint: HASH.runner, + }; +} + +function fixture() { + const variants = [ + { wasmOpt: "-O3", rawBytes: 1_000, gzipBytes: 500, brotliBytes: 400, hash: "2" }, + { wasmOpt: "-Os", rawBytes: 950, gzipBytes: 480, brotliBytes: 390, hash: "3" }, + { wasmOpt: "-Oz", rawBytes: 900, gzipBytes: 470, brotliBytes: 380, hash: "4" }, + ].map(({ wasmOpt, rawBytes, gzipBytes, brotliBytes, hash }) => { + const artifactSha256 = hash.repeat(64); + return { + wasmOpt, + inputWasmSha256: HASH.input, + artifact: { rawBytes, gzipBytes, brotliBytes, sha256: artifactSha256 }, + formulaGate: gate(artifactSha256, "formula"), + renderGate: gate(artifactSha256, "render"), + }; + }); + return { + schemaVersion: 1, + protocol: "wasm-post-link-profile-matrix-v1", + provenance: { + commit: COMMIT, + dirty: false, + timestamp: "2026-08-09T12:00:00.000Z", + host: "controlled-host", + rustc: "rustc 1.90.0", + cargo: "cargo 1.90.0", + wasmPack: "wasm-pack 0.13.1", + wasmOptVersion: "wasm-opt version 123", + wasmOptSha256: HASH.tool, + bun: "1.3.14", + }, + rustProfile: { optLevel: 3, lto: "fat", codegenUnits: 1 }, + postLink: { + inputWasmSha256: HASH.input, + compression: { gzipLevel: 9, brotliQuality: 11, brotliMode: "generic" }, + }, + decisionPolicy: { + sizeMetric: "brotliBytes", + requireFormulaGate: true, + requireRenderGate: true, + }, + captureOrder: { + formula: ["-O3", "-Oz", "-Os"], + render: ["-Os", "-O3", "-Oz"], + }, + variants, + storeOnlyProbe: { + provenance: { + commit: COMMIT, + dirty: false, + timestamp: "2026-08-09T12:01:00.000Z", + }, + command: [ + "cargo", + "check", + "--target", + "wasm32-unknown-unknown", + "--no-default-features", + "--message-format=json", + ], + outcome: "blocked", + exitCode: 101, + stdoutSha256: HASH.stdout, + stderrSha256: HASH.stderr, + blockers: [ + { + id: "formula-entry-ast", + kind: "dependency-cycle", + owner: "types::FormulaEntry::ast", + dependency: "calc::Ast", + reason: "stored syntax crosses the boundary", + }, + { + id: "dependency-index", + kind: "dependency-cycle", + owner: "store::CellStore::dep_index", + dependency: "eval::DepIndex", + reason: "dependency state crosses the boundary", + }, + ], + }, + }; +} + +describe("WASM post-link matrix evidence", () => { + it("keeps Rust opt-level separate and selects by the declared Brotli policy", () => { + const parsed = parseWasmProfileMatrix(fixture()); + expect(parsed.rustProfile.optLevel).toBe(3); + expect(parsed.variants.map(({ wasmOpt }) => wasmOpt)).toEqual(["-O3", "-Os", "-Oz"]); + expect(decideWasmProfile(parsed)).toEqual({ + status: "recommended", + wasmOpt: "-Oz", + baseline: "-O3", + savedBytes: { raw: 100, gzip: 30, brotli: 20 }, + }); + }); + + it("treats tracked and untracked worktree entries as dirty capture inputs", () => { + expect(() => assertCleanMatrixCapture(" M packages/wasm/Cargo.toml")).toThrow( + "requires a completely clean tree", + ); + expect(() => assertCleanMatrixCapture("?? local-input.wasm")).toThrow( + "requires a completely clean tree", + ); + expect(() => assertCleanMatrixCapture("")).not.toThrow(); + }); + + it("rejects a matrix that changes the Rust optimization knob", () => { + const evidence = fixture(); + evidence.rustProfile.optLevel = 2; + expect(() => parseWasmProfileMatrix(evidence)).toThrow("rustProfile.optLevel must be 3"); + }); + + it("rejects variants that do not share one pre-post-link artifact", () => { + const evidence = fixture(); + evidence.variants[1]!.inputWasmSha256 = "9".repeat(64); + expect(() => parseWasmProfileMatrix(evidence)).toThrow( + "-Os did not use the shared pre-post-link artifact", + ); + }); + + it("rejects incomplete profiles and size evidence", () => { + const missingProfile = fixture(); + missingProfile.variants.pop(); + expect(() => parseWasmProfileMatrix(missingProfile)).toThrow( + "variants must contain -O3, -Os, and -Oz exactly once", + ); + + const missingSize = fixture(); + Reflect.deleteProperty(missingSize.variants[2]!.artifact, "gzipBytes"); + expect(() => parseWasmProfileMatrix(missingSize)).toThrow("artifact.gzipBytes"); + }); + + it("rejects non-gating and report-only benchmark commands", () => { + const preliminary = fixture(); + preliminary.variants[0]!.formulaGate.commands = [ + ["bun", "run", "bench:formula", "--preliminary"], + ]; + expect(() => parseWasmProfileMatrix(preliminary)).toThrow( + "must run the gating formula benchmark", + ); + + const reportOnly = fixture(); + reportOnly.variants[0]!.renderGate.commands[1]!.push("--report-only"); + expect(() => parseWasmProfileMatrix(reportOnly)).toThrow( + "must run the render capture and fail-closed check", + ); + }); + + it("rejects attempts to opt out of either required performance gate", () => { + const evidence = fixture(); + evidence.decisionPolicy.requireRenderGate = false; + expect(() => parseWasmProfileMatrix(evidence)).toThrow( + "decisionPolicy.requireRenderGate must be true", + ); + }); + + it("rejects dirty or unmatched runner provenance", () => { + const dirty = fixture(); + dirty.provenance.dirty = true; + expect(() => parseWasmProfileMatrix(dirty)).toThrow("provenance.dirty must be false"); + + const unmatched = fixture(); + unmatched.variants[2]!.renderGate.runnerFingerprint = "9".repeat(64); + expect(() => parseWasmProfileMatrix(unmatched)).toThrow( + "renderGate runner provenance is incomparable", + ); + }); + + it("blocks the decision when any formula or render gate fails", () => { + const formulaFailure = fixture(); + formulaFailure.variants[2]!.formulaGate.status = "failed"; + formulaFailure.variants[2]!.formulaGate.exitCode = 1; + expect(decideWasmProfile(formulaFailure)).toEqual({ + status: "blocked", + failures: ["-Oz formula gate failed"], + }); + + const renderFailure = fixture(); + renderFailure.variants[1]!.renderGate.status = "failed"; + renderFailure.variants[1]!.renderGate.exitCode = 1; + expect(decideWasmProfile(renderFailure)).toEqual({ + status: "blocked", + failures: ["-Os render gate failed"], + }); + }); + + it("requires both structural blockers when the store-only probe is blocked", () => { + const parsed = parseWasmProfileMatrix(fixture()); + expect(parsed.storeOnlyProbe.outcome).toBe("blocked"); + expect(parsed.storeOnlyProbe.blockers.map(({ id }) => id)).toEqual([ + "formula-entry-ast", + "dependency-index", + ]); + + const incomplete = fixture(); + incomplete.storeOnlyProbe.blockers.pop(); + expect(() => parseWasmProfileMatrix(incomplete)).toThrow( + "must report both dependency-cycle blockers", + ); + }); + + it("binds the store-only probe to the clean matrix commit", () => { + const mismatched = fixture(); + mismatched.storeOnlyProbe.provenance.commit = "9".repeat(40); + expect(() => parseWasmProfileMatrix(mismatched)).toThrow( + "storeOnlyProbe commit does not match matrix provenance", + ); + + const dirty = fixture(); + dirty.storeOnlyProbe.provenance.dirty = true; + expect(() => parseWasmProfileMatrix(dirty)).toThrow( + "storeOnlyProbe.provenance.dirty must be false", + ); + }); + + it("does not let a successful probe retain blocker claims", () => { + const evidence = fixture(); + evidence.storeOnlyProbe.outcome = "compiled"; + evidence.storeOnlyProbe.exitCode = 0; + expect(() => parseWasmProfileMatrix(evidence)).toThrow( + "compiled store-only probe cannot claim dependency-cycle blockers", + ); + }); + + it("blocks a recommendation when the compile probe failure is unclassified", () => { + const evidence = fixture(); + evidence.storeOnlyProbe.outcome = "failed"; + evidence.storeOnlyProbe.blockers = []; + expect(decideWasmProfile(evidence)).toEqual({ + status: "blocked", + failures: ["store-only compile probe failed without a classified outcome"], + }); + }); + + it("rejects build-only captures as recommendation evidence", () => { + const evidence = fixture(); + evidence.protocol = "wasm-post-link-build-capture-v1"; + Reflect.deleteProperty(evidence, "captureOrder"); + expect(() => decideWasmProfile(evidence)).toThrow( + 'protocol must be "wasm-post-link-profile-matrix-v1"', + ); + }); +}); diff --git a/bun.lock b/bun.lock index 15ae109d..f4fc2a7d 100644 --- a/bun.lock +++ b/bun.lock @@ -938,7 +938,7 @@ "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], - "js-yaml": ["js-yaml@4.3.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q=="], + "js-yaml": ["js-yaml@4.3.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ=="], "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], @@ -1108,7 +1108,7 @@ "muggle-string": ["muggle-string@0.4.1", "", {}, "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ=="], - "nanoid": ["nanoid@3.3.16", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q=="], + "nanoid": ["nanoid@3.3.17", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g=="], "node-releases": ["node-releases@2.0.51", "", {}, "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ=="], @@ -1438,7 +1438,7 @@ "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], - "read-yaml-file/js-yaml": ["js-yaml@3.15.0", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog=="], + "read-yaml-file/js-yaml": ["js-yaml@3.15.1", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag=="], "tinyglobby/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], diff --git a/docs/src/content/docs/api/core.md b/docs/src/content/docs/api/core.md index c5a6ee18..5c7d9ab3 100644 --- a/docs/src/content/docs/api/core.md +++ b/docs/src/content/docs/api/core.md @@ -8,7 +8,7 @@ description: "API reference for @sheetwrite/core." Source entry: `packages/core/src/index.ts` @@ -37,18 +37,16 @@ Source entry: `packages/core/src/index.ts` XlsxResourceErrorStable resource-limit failure surfaced before an XLSX codec allocates unsafe data. -### Functions 60 +### Functions 58
assertRuntimeResourceSnapshotReject overlap, negative values, and attempts to fold runtime observations into owner totals. assertWorkbookTablesReject an invalid or oversized live workbook before table arrays are copied to WASM. cellA10-based (row, col) → A1 cell reference (0, 0 → "A1"). cellScalarToTextSpreadsheet display text for a resolved scalar. -cloneCellHyperlinkClone one validated hyperlink into an independent plain-data value. colToA1Column index (0-based) → A1 column label (0 → "A", 26 → "AA"). createGridCreates and mounts an imperative Grid in the supplied host element. createGridFromSnapshotMount a grid over a validated, non-dirty snapshot. -createHyperlinkIdProduce a new stable identity for copied hyperlinks without trusting clipboard IDs. createRowBridgeCreate a typed bridge while preserving row and identity inference. createRuntimeResourceSnapshotBuild and validate one operation-phase snapshot without double-counting runtime observations. dateToSerialConvert a real UTC Date to the Excel 1900-system serial. diff --git a/docs/src/content/docs/api/core/clone-cell-hyperlink.md b/docs/src/content/docs/api/core/clone-cell-hyperlink.md deleted file mode 100644 index 3ac863c6..00000000 --- a/docs/src/content/docs/api/core/clone-cell-hyperlink.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -title: "cloneCellHyperlink | @sheetwrite/core" -description: "Clone one validated hyperlink into an independent plain-data value." ---- - - - -Clone one validated hyperlink into an independent plain-data value. - - - -## Declaration - -
- -```ts generated -function cloneCellHyperlink(link: CellHyperlink): CellHyperlink -``` - -
diff --git a/docs/src/content/docs/api/core/create-hyperlink-id.md b/docs/src/content/docs/api/core/create-hyperlink-id.md deleted file mode 100644 index 8f94f190..00000000 --- a/docs/src/content/docs/api/core/create-hyperlink-id.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -title: "createHyperlinkId | @sheetwrite/core" -description: "Produce a new stable identity for copied hyperlinks without trusting clipboard IDs." ---- - - - -Produce a new stable identity for copied hyperlinks without trusting clipboard IDs. - - - -## Declaration - -
- -```ts generated -function createHyperlinkId(): string -``` - -
diff --git a/docs/src/content/docs/api/core/incomplete-data-error.md b/docs/src/content/docs/api/core/incomplete-data-error.md index 3e329e1b..53ef5213 100644 --- a/docs/src/content/docs/api/core/incomplete-data-error.md +++ b/docs/src/content/docs/api/core/incomplete-data-error.md @@ -9,7 +9,7 @@ Error thrown when an operation requires datasource cells that are not loaded. ## Members 3 diff --git a/docs/src/content/docs/api/index.md b/docs/src/content/docs/api/index.md index a6c9b60f..6b9aecd9 100644 --- a/docs/src/content/docs/api/index.md +++ b/docs/src/content/docs/api/index.md @@ -9,7 +9,7 @@ The package `exports` maps define this inventory. Supported, internal, asset, an - + diff --git a/docs/src/content/docs/api/wasm/cell-store.md b/docs/src/content/docs/api/wasm/cell-store.md index fdca90c4..3a840706 100644 --- a/docs/src/content/docs/api/wasm/cell-store.md +++ b/docs/src/content/docs/api/wasm/cell-store.md @@ -310,9 +310,9 @@ getWindow: (sheet: number, row_start: number, row_end: number, cols: Uint32Array ```

One bulk read of a rectangular window for the renderer. Returns -contiguous typed arrays (row-major over `rows x cols`) plus the unique -strings referenced by the window, so the host paints without crossing -the boundary per cell.

+contiguous packed cell data (row-major over `rows x cols`) plus the +unique strings referenced by the window, so the host paints without +crossing the boundary per cell.

diff --git a/docs/src/content/docs/api/wasm/init-input.md b/docs/src/content/docs/api/wasm/init-input.md index f307b690..35148302 100644 --- a/docs/src/content/docs/api/wasm/init-input.md +++ b/docs/src/content/docs/api/wasm/init-input.md @@ -9,7 +9,7 @@ Sources accepted by asynchronous initialization: a fetchable URL/request/respons ## Declaration diff --git a/docs/src/content/docs/api/wasm/init-output.md b/docs/src/content/docs/api/wasm/init-output.md index 5c84d387..c1292687 100644 --- a/docs/src/content/docs/api/wasm/init-output.md +++ b/docs/src/content/docs/api/wasm/init-output.md @@ -9,10 +9,10 @@ Result of module initialization: the instantiated exports plus the shared linear -## Members 134 +## Members 128
@@ -1078,42 +1078,6 @@ readonly windowview_nCols: (a: number) => number;
-
-windowview_nRows - -```ts generated -readonly windowview_nRows: (a: number) => number; -``` - -
- -
-windowview_takeCondMatches - -```ts generated -readonly windowview_takeCondMatches: (a: number) => [number, number]; -``` - -
- -
-windowview_takeStringIds - -```ts generated -readonly windowview_takeStringIds: (a: number) => [number, number]; -``` - -
- -
-windowview_takeStringIndex - -```ts generated -readonly windowview_takeStringIndex: (a: number) => [number, number]; -``` - -
-
windowview_takeStrings @@ -1123,38 +1087,20 @@ readonly windowview_takeStrings: (a: number) => [number, number];
-
-windowview_takeStyleDict - -```ts generated -readonly windowview_takeStyleDict: (a: number) => [number, number]; -``` - -
- -
-windowview_takeStyleIndex +
+windowview_takePacked ```ts generated -readonly windowview_takeStyleIndex: (a: number) => [number, number]; +readonly windowview_takePacked: (a: number) => [number, number]; ```
-
-windowview_takeKinds - -```ts generated -readonly windowview_takeKinds: (a: number) => [number, number]; -``` - -
- -
-windowview_takeNumbers +
+windowview_nRows ```ts generated -readonly windowview_takeNumbers: (a: number) => [number, number]; +readonly windowview_nRows: (a: number) => number; ```
@@ -1832,15 +1778,9 @@ export interface InitOutput { readonly sourcesnapshot_referenceTargets: (a: number) => [number, number]; readonly sourcesnapshot_spillDerived: (a: number) => [number, number]; readonly windowview_nCols: (a: number) => number; - readonly windowview_nRows: (a: number) => number; - readonly windowview_takeCondMatches: (a: number) => [number, number]; - readonly windowview_takeStringIds: (a: number) => [number, number]; - readonly windowview_takeStringIndex: (a: number) => [number, number]; readonly windowview_takeStrings: (a: number) => [number, number]; - readonly windowview_takeStyleDict: (a: number) => [number, number]; - readonly windowview_takeStyleIndex: (a: number) => [number, number]; - readonly windowview_takeKinds: (a: number) => [number, number]; - readonly windowview_takeNumbers: (a: number) => [number, number]; + readonly windowview_takePacked: (a: number) => [number, number]; + readonly windowview_nRows: (a: number) => number; readonly __wbindgen_malloc: (a: number, b: number) => number; readonly __wbindgen_realloc: ( a: number, diff --git a/docs/src/content/docs/api/wasm/init-sync.md b/docs/src/content/docs/api/wasm/init-sync.md index 77611164..f2108bac 100644 --- a/docs/src/content/docs/api/wasm/init-sync.md +++ b/docs/src/content/docs/api/wasm/init-sync.md @@ -10,7 +10,7 @@ a precompiled `WebAssembly.Module`. ## Declaration diff --git a/docs/src/content/docs/api/wasm/sync-init-input.md b/docs/src/content/docs/api/wasm/sync-init-input.md index 3ce265f9..ae69191c 100644 --- a/docs/src/content/docs/api/wasm/sync-init-input.md +++ b/docs/src/content/docs/api/wasm/sync-init-input.md @@ -9,7 +9,7 @@ Sources accepted by synchronous initialization: raw module bytes or a precompile ## Declaration diff --git a/docs/src/content/docs/api/wasm/window-view.md b/docs/src/content/docs/api/wasm/window-view.md index 0590bfbe..8308cb11 100644 --- a/docs/src/content/docs/api/wasm/window-view.md +++ b/docs/src/content/docs/api/wasm/window-view.md @@ -12,7 +12,7 @@ A bulk window of resolved cells, row-major over `n_rows x n_cols`.
Source
packages/wasm/pkg/sheetwrite_wasm.d.ts#L367
-## Members 11 +## Members 5
@@ -43,74 +43,22 @@ nRows: number;
-
-takeCondMatches Consume and return per-cell conditional-format rule bitmasks; empty when the sheet has no rules. +
+takePacked Consume the complete fixed-width window payload. ```ts generated -takeCondMatches: () => Uint32Array; -``` - -
- -
-takeKinds Consume and return the per-cell tag array: 0 empty, 1 number, 2 string. - -```ts generated -takeKinds: () => Uint8Array; -``` - -
- -
-takeNumbers Consume and return per-cell numeric payloads (valid where kind == 1). - -```ts generated -takeNumbers: () => Float64Array; -``` - -
- -
-takeStringIds Consume and return per-cell GLOBAL string-pool ids; u32::MAX means none. - -```ts generated -takeStringIds: () => Uint32Array; -``` - -
- -
-takeStringIndex Consume and return per-cell indices into strings (string cells only). - -```ts generated -takeStringIndex: () => Int32Array; +takePacked: () => Uint8Array; ``` +

Consume the complete fixed-width window payload. The returned +`Uint8Array` is copied by wasm-bindgen into JS-owned memory.

-takeStrings Consume and return unique strings referenced by this window. - -```ts generated -takeStrings: () => string[]; -``` - -
- -
-takeStyleDict Consume and return the unique global style-dictionary ids referenced by this window, in local-index order; the host maps each to a CellStyle. - -```ts generated -takeStyleDict: () => Uint32Array; -``` - -
- -
-takeStyleIndex Consume and return per-cell WINDOW-LOCAL style indices into the dict. +takeStrings Consume formula-error sentinel strings referenced by the packed data. ```ts generated -takeStyleIndex: () => Uint32Array +takeStrings: () => string[] ```
@@ -126,14 +74,8 @@ class WindowView { free: () => void; nCols: number; nRows: number; - takeCondMatches: () => Uint32Array; - takeKinds: () => Uint8Array; - takeNumbers: () => Float64Array; - takeStringIds: () => Uint32Array; - takeStringIndex: () => Int32Array; + takePacked: () => Uint8Array; takeStrings: () => string[]; - takeStyleDict: () => Uint32Array; - takeStyleIndex: () => Uint32Array; } ``` diff --git a/docs/src/generated/docs-contract.json b/docs/src/generated/docs-contract.json index c3dd167c..c88fd192 100644 --- a/docs/src/generated/docs-contract.json +++ b/docs/src/generated/docs-contract.json @@ -23,9 +23,9 @@ "toXlsxWorkbook" ], "entryPointCount": 18, - "exportCount": 468, - "symbolPageCount": 468, - "apiSha256": "82b1c79b103c9a2ac16749c3583014ee202785a846e1b3e62769328eeb6c0ceb", + "exportCount": 466, + "symbolPageCount": 466, + "apiSha256": "7598d0c8fef4270d3a0e35fd0138492103d76caca9b7779b05a502c85ac4d800", "compatibilitySha256": "3dc63d06901349f4edd5b2b5bc332b87cdbecacc54d303c85eb2b0b1354ed01a", "formulaContractSha256": "c19bbb023fd08bd378ca2d0b82761fa1b203e29f93a5d4b32bf4a2e6ce4affaf" } diff --git a/docs/src/generated/public-api.json b/docs/src/generated/public-api.json index 075a1607..965c122e 100644 --- a/docs/src/generated/public-api.json +++ b/docs/src/generated/public-api.json @@ -489,18 +489,6 @@ } ] }, - { - "name": "cloneCellHyperlink", - "kind": "function", - "signature": "(link: CellHyperlink): CellHyperlink", - "owners": [ - "src/hyperlink.ts" - ], - "source": "src/hyperlink.ts#L263", - "jsDocTags": [], - "documentation": "Clone one validated hyperlink into an independent plain-data value.", - "memberDocs": [] - }, { "name": "colToA1", "kind": "function", @@ -893,18 +881,6 @@ "documentation": "Mount a grid over a validated, non-dirty snapshot. The grid owns and disposes\nthe hydrated store just like one created through `createGrid`.", "memberDocs": [] }, - { - "name": "createHyperlinkId", - "kind": "function", - "signature": "(): string", - "owners": [ - "src/hyperlink.ts" - ], - "source": "src/hyperlink.ts#L329", - "jsDocTags": [], - "documentation": "Produce a new stable identity for copied hyperlinks without trusting clipboard IDs.", - "memberDocs": [] - }, { "name": "createRowBridge", "kind": "function", @@ -2041,7 +2017,7 @@ "owners": [ "src/store/data-engine.ts" ], - "source": "src/store/data-engine.ts#L245", + "source": "src/store/data-engine.ts#L242", "jsDocTags": [], "documentation": "Error thrown when an operation requires datasource cells that are not loaded.", "memberDocs": [] @@ -9411,7 +9387,7 @@ }, { "name": "getWindow", - "documentation": "One bulk read of a rectangular window for the renderer. Returns\ncontiguous typed arrays (row-major over `rows x cols`) plus the unique\nstrings referenced by the window, so the host paints without crossing\nthe boundary per cell." + "documentation": "One bulk read of a rectangular window for the renderer. Returns\ncontiguous packed cell data (row-major over `rows x cols`) plus the\nunique strings referenced by the window, so the host paints without\ncrossing the boundary per cell." }, { "name": "getWindowRows", @@ -9563,7 +9539,7 @@ "owners": [ "pkg/sheetwrite_wasm.d.ts" ], - "source": "pkg/sheetwrite_wasm.d.ts#L409", + "source": "pkg/sheetwrite_wasm.d.ts#L384", "jsDocTags": [], "documentation": "Sources accepted by asynchronous initialization: a fetchable URL/request/response, raw module bytes, or a precompiled `WebAssembly.Module`.", "memberDocs": [] @@ -9571,11 +9547,11 @@ { "name": "InitOutput", "kind": "interface", - "signature": "export interface InitOutput { readonly memory: WebAssembly.Memory; readonly __wbg_cellout_free: (a: number, b: number) => void; readonly __wbg_cellstore_free: (a: number, b: number) => void; readonly __wbg_distinctcolumn_free: (a: number, b: number) => void; readonly __wbg_rangesnapshot_free: (a: number, b: number) => void; readonly __wbg_sourcesnapshot_free: (a: number, b: number) => void; readonly __wbg_windowview_free: (a: number, b: number) => void; readonly cellout_kind: (a: number) => number; readonly cellout_num: (a: number) => number; readonly cellout_string: (a: number) => [number, number]; readonly cellout_style: (a: number) => number; readonly cellstore_acknowledgeRevision: (a: number, b: bigint) => void; readonly cellstore_addPagedSheet: (a: number, b: number, c: number, d: number, e: number, f: number) => number; readonly cellstore_addRows: (a: number, b: number, c: number, d: number) => void; readonly cellstore_addSheet: (a: number, b: number, c: number) => number; readonly cellstore_aggregate: (a: number, b: number, c: number, d: number) => number; readonly cellstore_beginMutation: (a: number) => bigint; readonly cellstore_beginPageLoad: (a: number) => void; readonly cellstore_canDirtyCell: (a: number, b: number, c: number, d: number) => number; readonly cellstore_captureRange: (a: number, b: number, c: number, d: number, e: number, f: number) => number; readonly cellstore_captureReferences: (a: number, b: number, c: number) => number; readonly cellstore_captureSources: (a: number, b: number, c: number, d: number, e: number, f: number) => number; readonly cellstore_captureSourcesForRows: (a: number, b: number, c: number, d: number, e: number, f: number) => number; readonly cellstore_cellState: (a: number, b: number, c: number, d: number) => number; readonly cellstore_clearCell: (a: number, b: number, c: number, d: number, e: number) => void; readonly cellstore_clearRange: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number) => number; readonly cellstore_colCount: (a: number, b: number) => number; readonly cellstore_columnsFullyLoaded: (a: number, b: number, c: number, d: number, e: number, f: number) => number; readonly cellstore_compactStringStorage: (a: number) => void; readonly cellstore_dataEdge: (a: number, b: number, c: number, d: number, e: number, f: number) => number; readonly cellstore_dataEdgeOrdered: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number) => number; readonly cellstore_dirtyRevision: (a: number, b: number, c: number, d: number) => bigint; readonly cellstore_distinctValues: (a: number, b: number, c: number, d: number) => number; readonly cellstore_endMutation: (a: number) => void; readonly cellstore_endPageLoad: (a: number) => void; readonly cellstore_filterRows: (a: number, b: number, c: number, d: number, e: number) => [number, number]; readonly cellstore_filterRowsMulti: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number, i: number, j: number, k: number, l: number, m: number, n: number, o: number, p: number, q: number, r: number) => [number, number]; readonly cellstore_formulaMatrixResourceStats: (a: number) => [number, number]; readonly cellstore_formulaSource: (a: number, b: number, c: number, d: number) => [number, number]; readonly cellstore_getCell: (a: number, b: number, c: number, d: number) => number; readonly cellstore_getWindow: (a: number, b: number, c: number, d: number, e: number, f: number) => number; readonly cellstore_getWindowRows: (a: number, b: number, c: number, d: number, e: number, f: number) => number; readonly cellstore_hydratePageNumbers: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number, i: number) => void; readonly cellstore_hydratePageStringsPacked: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number, i: number, j: number, k: number) => void; readonly cellstore_insertCols: (a: number, b: number, c: number, d: number) => void; readonly cellstore_isFullyLoaded: (a: number, b: number) => number; readonly cellstore_isPaged: (a: number, b: number) => number; readonly cellstore_isSheetAlive: (a: number, b: number) => number; readonly cellstore_markCellCleanRevision: (a: number, b: number, c: number, d: number, e: bigint) => number; readonly cellstore_markRangeClean: (a: number, b: number, c: number, d: number, e: number, f: number) => void; readonly cellstore_memoryStats: (a: number) => [number, number]; readonly cellstore_new: () => number; readonly cellstore_pagedDirtyCoordinates: (a: number, b: number) => [number, number]; readonly cellstore_pagedStats: (a: number, b: number) => [number, number]; readonly cellstore_persistedCellData: (a: number, b: number) => [number, number]; readonly cellstore_pinRange: (a: number, b: number, c: number, d: number, e: number, f: number) => void; readonly cellstore_poolStrings: (a: number, b: number, c: number) => [number, number]; readonly cellstore_queryResourceStats: (a: number) => [number, number]; readonly cellstore_rangeFullyLoaded: (a: number, b: number, c: number, d: number, e: number, f: number) => number; readonly cellstore_rangeStyleIds: (a: number, b: number, c: number, d: number, e: number, f: number) => [number, number]; readonly cellstore_recompute: (a: number, b: number) => void; readonly cellstore_recomputeChanged: (a: number) => void; readonly cellstore_recomputeVolatile: (a: number, b: number) => number; readonly cellstore_referenceTarget: (a: number, b: number, c: number, d: number) => [number, number]; readonly cellstore_referencesTargeting: (a: number, b: number, c: number) => [number, number]; readonly cellstore_remapRangeStyles: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number, i: number, j: number) => number; readonly cellstore_removeCols: (a: number, b: number, c: number, d: number) => void; readonly cellstore_removeNamedRange: (a: number, b: number, c: number, d: number) => number; readonly cellstore_removeRows: (a: number, b: number, c: number, d: number) => void; readonly cellstore_removeSheet: (a: number, b: number) => number; readonly cellstore_removeTable: (a: number, b: number, c: number) => number; readonly cellstore_renameSheet: (a: number, b: number, c: number, d: number, e: number, f: number) => number; readonly cellstore_resetFormulaMatrixResourceStats: (a: number) => void; readonly cellstore_resetQueryResourceStats: (a: number) => void; readonly cellstore_restoreRange: (a: number, b: number, c: number, d: number, e: number) => number; readonly cellstore_rowCount: (a: number, b: number) => number; readonly cellstore_search: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number) => [number, number]; readonly cellstore_setBlock: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number, i: number, j: number, k: number, l: number, m: number, n: number, o: number, p: number, q: number, r: number, s: number, t: number, u: number, v: number) => number; readonly cellstore_setBool: (a: number, b: number, c: number, d: number, e: number, f: number) => void; readonly cellstore_setColumnNumbers: (a: number, b: number, c: number, d: number, e: number, f: number, g: number) => void; readonly cellstore_setColumnStrings: (a: number, b: number, c: number, d: number, e: number, f: number, g: number) => void; readonly cellstore_setColumnStringsPacked: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number, i: number) => void; readonly cellstore_setConditionalRules: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number, i: number, j: number, k: number, l: number) => void; readonly cellstore_setFormula: (a: number, b: number, c: number, d: number, e: number, f: number, g: number) => number; readonly cellstore_setNamedRange: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number, i: number) => number; readonly cellstore_setNumber: (a: number, b: number, c: number, d: number, e: number, f: number) => void; readonly cellstore_setSheetName: (a: number, b: number, c: number, d: number, e: number, f: number) => void; readonly cellstore_setSparseBlock: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number, i: number, j: number, k: number, l: number, m: number, n: number, o: number, p: number, q: number, r: number, s: number, t: number, u: number, v: number, w: number, x: number) => number; readonly cellstore_setSpillBlockers: (a: number, b: number, c: number, d: number) => number; readonly cellstore_setString: (a: number, b: number, c: number, d: number, e: number, f: number, g: number) => void; readonly cellstore_setTable: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number, i: number, j: number, k: number, l: number, m: number, n: number, o: number, p: number) => number; readonly cellstore_snapshotNumbers: (a: number, b: number) => [number, number]; readonly cellstore_snapshotTexts: (a: number, b: number) => [number, number]; readonly cellstore_sortRows: (a: number, b: number, c: number, d: number) => [number, number]; readonly cellstore_sortRowsMulti: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number) => [number, number]; readonly cellstore_spillAnchorCol: (a: number, b: number, c: number, d: number) => number; readonly cellstore_spillAnchorRow: (a: number, b: number, c: number, d: number) => number; readonly cellstore_spillDerivedMask: (a: number, b: number, c: number, d: number, e: number, f: number) => [number, number]; readonly cellstore_spillOwnerCoordinates: (a: number, b: number, c: number, d: number, e: number, f: number) => [number, number]; readonly cellstore_styleIdAt: (a: number, b: number, c: number, d: number) => number; readonly cellstore_wasmCommittedBytes: (a: number) => number; readonly distinctcolumn_takeKinds: (a: number) => [number, number]; readonly distinctcolumn_takeNumbers: (a: number) => [number, number]; readonly distinctcolumn_takeTexts: (a: number) => [number, number]; readonly rangesnapshot_byteLength: (a: number) => number; readonly rangesnapshot_formulaOffsets: (a: number) => [number, number]; readonly rangesnapshot_formulaSources: (a: number) => [number, number]; readonly rangesnapshot_kinds: (a: number) => [number, number]; readonly rangesnapshot_referenceOffsets: (a: number) => [number, number]; readonly rangesnapshot_referenceTargets: (a: number) => [number, number]; readonly rangesnapshot_styleIds: (a: number) => [number, number]; readonly sourcesnapshot_byteLength: (a: number) => number; readonly sourcesnapshot_formulaOffsets: (a: number) => [number, number]; readonly sourcesnapshot_formulaSources: (a: number) => [number, number]; readonly sourcesnapshot_referenceOffsets: (a: number) => [number, number]; readonly sourcesnapshot_referenceTargets: (a: number) => [number, number]; readonly sourcesnapshot_spillDerived: (a: number) => [number, number]; readonly windowview_nCols: (a: number) => number; readonly windowview_nRows: (a: number) => number; readonly windowview_takeCondMatches: (a: number) => [number, number]; readonly windowview_takeStringIds: (a: number) => [number, number]; readonly windowview_takeStringIndex: (a: number) => [number, number]; readonly windowview_takeStrings: (a: number) => [number, number]; readonly windowview_takeStyleDict: (a: number) => [number, number]; readonly windowview_takeStyleIndex: (a: number) => [number, number]; readonly windowview_takeKinds: (a: number) => [number, number]; readonly windowview_takeNumbers: (a: number) => [number, number]; readonly __wbindgen_malloc: (a: number, b: number) => number; readonly __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number; readonly __wbindgen_externrefs: WebAssembly.Table; readonly __wbindgen_free: (a: number, b: number, c: number) => void; readonly __externref_table_alloc: () => number; readonly __externref_drop_slice: (a: number, b: number) => void; readonly __wbindgen_start: () => void; }", + "signature": "export interface InitOutput { readonly memory: WebAssembly.Memory; readonly __wbg_cellout_free: (a: number, b: number) => void; readonly __wbg_cellstore_free: (a: number, b: number) => void; readonly __wbg_distinctcolumn_free: (a: number, b: number) => void; readonly __wbg_rangesnapshot_free: (a: number, b: number) => void; readonly __wbg_sourcesnapshot_free: (a: number, b: number) => void; readonly __wbg_windowview_free: (a: number, b: number) => void; readonly cellout_kind: (a: number) => number; readonly cellout_num: (a: number) => number; readonly cellout_string: (a: number) => [number, number]; readonly cellout_style: (a: number) => number; readonly cellstore_acknowledgeRevision: (a: number, b: bigint) => void; readonly cellstore_addPagedSheet: (a: number, b: number, c: number, d: number, e: number, f: number) => number; readonly cellstore_addRows: (a: number, b: number, c: number, d: number) => void; readonly cellstore_addSheet: (a: number, b: number, c: number) => number; readonly cellstore_aggregate: (a: number, b: number, c: number, d: number) => number; readonly cellstore_beginMutation: (a: number) => bigint; readonly cellstore_beginPageLoad: (a: number) => void; readonly cellstore_canDirtyCell: (a: number, b: number, c: number, d: number) => number; readonly cellstore_captureRange: (a: number, b: number, c: number, d: number, e: number, f: number) => number; readonly cellstore_captureReferences: (a: number, b: number, c: number) => number; readonly cellstore_captureSources: (a: number, b: number, c: number, d: number, e: number, f: number) => number; readonly cellstore_captureSourcesForRows: (a: number, b: number, c: number, d: number, e: number, f: number) => number; readonly cellstore_cellState: (a: number, b: number, c: number, d: number) => number; readonly cellstore_clearCell: (a: number, b: number, c: number, d: number, e: number) => void; readonly cellstore_clearRange: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number) => number; readonly cellstore_colCount: (a: number, b: number) => number; readonly cellstore_columnsFullyLoaded: (a: number, b: number, c: number, d: number, e: number, f: number) => number; readonly cellstore_compactStringStorage: (a: number) => void; readonly cellstore_dataEdge: (a: number, b: number, c: number, d: number, e: number, f: number) => number; readonly cellstore_dataEdgeOrdered: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number) => number; readonly cellstore_dirtyRevision: (a: number, b: number, c: number, d: number) => bigint; readonly cellstore_distinctValues: (a: number, b: number, c: number, d: number) => number; readonly cellstore_endMutation: (a: number) => void; readonly cellstore_endPageLoad: (a: number) => void; readonly cellstore_filterRows: (a: number, b: number, c: number, d: number, e: number) => [number, number]; readonly cellstore_filterRowsMulti: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number, i: number, j: number, k: number, l: number, m: number, n: number, o: number, p: number, q: number, r: number) => [number, number]; readonly cellstore_formulaMatrixResourceStats: (a: number) => [number, number]; readonly cellstore_formulaSource: (a: number, b: number, c: number, d: number) => [number, number]; readonly cellstore_getCell: (a: number, b: number, c: number, d: number) => number; readonly cellstore_getWindow: (a: number, b: number, c: number, d: number, e: number, f: number) => number; readonly cellstore_getWindowRows: (a: number, b: number, c: number, d: number, e: number, f: number) => number; readonly cellstore_hydratePageNumbers: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number, i: number) => void; readonly cellstore_hydratePageStringsPacked: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number, i: number, j: number, k: number) => void; readonly cellstore_insertCols: (a: number, b: number, c: number, d: number) => void; readonly cellstore_isFullyLoaded: (a: number, b: number) => number; readonly cellstore_isPaged: (a: number, b: number) => number; readonly cellstore_isSheetAlive: (a: number, b: number) => number; readonly cellstore_markCellCleanRevision: (a: number, b: number, c: number, d: number, e: bigint) => number; readonly cellstore_markRangeClean: (a: number, b: number, c: number, d: number, e: number, f: number) => void; readonly cellstore_memoryStats: (a: number) => [number, number]; readonly cellstore_new: () => number; readonly cellstore_pagedDirtyCoordinates: (a: number, b: number) => [number, number]; readonly cellstore_pagedStats: (a: number, b: number) => [number, number]; readonly cellstore_persistedCellData: (a: number, b: number) => [number, number]; readonly cellstore_pinRange: (a: number, b: number, c: number, d: number, e: number, f: number) => void; readonly cellstore_poolStrings: (a: number, b: number, c: number) => [number, number]; readonly cellstore_queryResourceStats: (a: number) => [number, number]; readonly cellstore_rangeFullyLoaded: (a: number, b: number, c: number, d: number, e: number, f: number) => number; readonly cellstore_rangeStyleIds: (a: number, b: number, c: number, d: number, e: number, f: number) => [number, number]; readonly cellstore_recompute: (a: number, b: number) => void; readonly cellstore_recomputeChanged: (a: number) => void; readonly cellstore_recomputeVolatile: (a: number, b: number) => number; readonly cellstore_referenceTarget: (a: number, b: number, c: number, d: number) => [number, number]; readonly cellstore_referencesTargeting: (a: number, b: number, c: number) => [number, number]; readonly cellstore_remapRangeStyles: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number, i: number, j: number) => number; readonly cellstore_removeCols: (a: number, b: number, c: number, d: number) => void; readonly cellstore_removeNamedRange: (a: number, b: number, c: number, d: number) => number; readonly cellstore_removeRows: (a: number, b: number, c: number, d: number) => void; readonly cellstore_removeSheet: (a: number, b: number) => number; readonly cellstore_removeTable: (a: number, b: number, c: number) => number; readonly cellstore_renameSheet: (a: number, b: number, c: number, d: number, e: number, f: number) => number; readonly cellstore_resetFormulaMatrixResourceStats: (a: number) => void; readonly cellstore_resetQueryResourceStats: (a: number) => void; readonly cellstore_restoreRange: (a: number, b: number, c: number, d: number, e: number) => number; readonly cellstore_rowCount: (a: number, b: number) => number; readonly cellstore_search: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number) => [number, number]; readonly cellstore_setBlock: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number, i: number, j: number, k: number, l: number, m: number, n: number, o: number, p: number, q: number, r: number, s: number, t: number, u: number, v: number) => number; readonly cellstore_setBool: (a: number, b: number, c: number, d: number, e: number, f: number) => void; readonly cellstore_setColumnNumbers: (a: number, b: number, c: number, d: number, e: number, f: number, g: number) => void; readonly cellstore_setColumnStrings: (a: number, b: number, c: number, d: number, e: number, f: number, g: number) => void; readonly cellstore_setColumnStringsPacked: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number, i: number) => void; readonly cellstore_setConditionalRules: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number, i: number, j: number, k: number, l: number) => void; readonly cellstore_setFormula: (a: number, b: number, c: number, d: number, e: number, f: number, g: number) => number; readonly cellstore_setNamedRange: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number, i: number) => number; readonly cellstore_setNumber: (a: number, b: number, c: number, d: number, e: number, f: number) => void; readonly cellstore_setSheetName: (a: number, b: number, c: number, d: number, e: number, f: number) => void; readonly cellstore_setSparseBlock: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number, i: number, j: number, k: number, l: number, m: number, n: number, o: number, p: number, q: number, r: number, s: number, t: number, u: number, v: number, w: number, x: number) => number; readonly cellstore_setSpillBlockers: (a: number, b: number, c: number, d: number) => number; readonly cellstore_setString: (a: number, b: number, c: number, d: number, e: number, f: number, g: number) => void; readonly cellstore_setTable: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number, i: number, j: number, k: number, l: number, m: number, n: number, o: number, p: number) => number; readonly cellstore_snapshotNumbers: (a: number, b: number) => [number, number]; readonly cellstore_snapshotTexts: (a: number, b: number) => [number, number]; readonly cellstore_sortRows: (a: number, b: number, c: number, d: number) => [number, number]; readonly cellstore_sortRowsMulti: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number) => [number, number]; readonly cellstore_spillAnchorCol: (a: number, b: number, c: number, d: number) => number; readonly cellstore_spillAnchorRow: (a: number, b: number, c: number, d: number) => number; readonly cellstore_spillDerivedMask: (a: number, b: number, c: number, d: number, e: number, f: number) => [number, number]; readonly cellstore_spillOwnerCoordinates: (a: number, b: number, c: number, d: number, e: number, f: number) => [number, number]; readonly cellstore_styleIdAt: (a: number, b: number, c: number, d: number) => number; readonly cellstore_wasmCommittedBytes: (a: number) => number; readonly distinctcolumn_takeKinds: (a: number) => [number, number]; readonly distinctcolumn_takeNumbers: (a: number) => [number, number]; readonly distinctcolumn_takeTexts: (a: number) => [number, number]; readonly rangesnapshot_byteLength: (a: number) => number; readonly rangesnapshot_formulaOffsets: (a: number) => [number, number]; readonly rangesnapshot_formulaSources: (a: number) => [number, number]; readonly rangesnapshot_kinds: (a: number) => [number, number]; readonly rangesnapshot_referenceOffsets: (a: number) => [number, number]; readonly rangesnapshot_referenceTargets: (a: number) => [number, number]; readonly rangesnapshot_styleIds: (a: number) => [number, number]; readonly sourcesnapshot_byteLength: (a: number) => number; readonly sourcesnapshot_formulaOffsets: (a: number) => [number, number]; readonly sourcesnapshot_formulaSources: (a: number) => [number, number]; readonly sourcesnapshot_referenceOffsets: (a: number) => [number, number]; readonly sourcesnapshot_referenceTargets: (a: number) => [number, number]; readonly sourcesnapshot_spillDerived: (a: number) => [number, number]; readonly windowview_nCols: (a: number) => number; readonly windowview_takeStrings: (a: number) => [number, number]; readonly windowview_takePacked: (a: number) => [number, number]; readonly windowview_nRows: (a: number) => number; readonly __wbindgen_malloc: (a: number, b: number) => number; readonly __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number; readonly __wbindgen_externrefs: WebAssembly.Table; readonly __wbindgen_free: (a: number, b: number, c: number) => void; readonly __externref_table_alloc: () => number; readonly __externref_drop_slice: (a: number, b: number) => void; readonly __wbindgen_start: () => void; }", "owners": [ "pkg/sheetwrite_wasm.d.ts" ], - "source": "pkg/sheetwrite_wasm.d.ts#L411", + "source": "pkg/sheetwrite_wasm.d.ts#L386", "jsDocTags": [], "documentation": "Result of module initialization: the instantiated exports plus the shared linear memory.", "memberDocs": [] @@ -9587,7 +9563,7 @@ "owners": [ "pkg/sheetwrite_wasm.d.ts" ], - "source": "pkg/sheetwrite_wasm.d.ts#L558", + "source": "pkg/sheetwrite_wasm.d.ts#L527", "jsDocTags": [ "param", "returns" @@ -9650,7 +9626,7 @@ "owners": [ "pkg/sheetwrite_wasm.d.ts" ], - "source": "pkg/sheetwrite_wasm.d.ts#L548", + "source": "pkg/sheetwrite_wasm.d.ts#L517", "jsDocTags": [], "documentation": "Sources accepted by synchronous initialization: raw module bytes or a precompiled `WebAssembly.Module`.", "memberDocs": [] @@ -9658,7 +9634,7 @@ { "name": "WindowView", "kind": "class", - "signature": "class WindowView { free: () => void; nCols: number; nRows: number; takeCondMatches: () => Uint32Array; takeKinds: () => Uint8Array; takeNumbers: () => Float64Array; takeStringIds: () => Uint32Array; takeStringIndex: () => Int32Array; takeStrings: () => string[]; takeStyleDict: () => Uint32Array; takeStyleIndex: () => Uint32Array }", + "signature": "class WindowView { free: () => void; nCols: number; nRows: number; takePacked: () => Uint8Array; takeStrings: () => string[] }", "owners": [ "pkg/sheetwrite_wasm.d.ts" ], @@ -9667,36 +9643,12 @@ "documentation": "A bulk window of resolved cells, row-major over `n_rows x n_cols`.", "memberDocs": [ { - "name": "takeCondMatches", - "documentation": "Consume and return per-cell conditional-format rule bitmasks; empty\nwhen the sheet has no rules." - }, - { - "name": "takeKinds", - "documentation": "Consume and return the per-cell tag array: 0 empty, 1 number, 2 string." - }, - { - "name": "takeNumbers", - "documentation": "Consume and return per-cell numeric payloads (valid where kind == 1)." - }, - { - "name": "takeStringIds", - "documentation": "Consume and return per-cell GLOBAL string-pool ids; `u32::MAX` means none." - }, - { - "name": "takeStringIndex", - "documentation": "Consume and return per-cell indices into `strings` (string cells only)." + "name": "takePacked", + "documentation": "Consume the complete fixed-width window payload. The returned\n`Uint8Array` is copied by wasm-bindgen into JS-owned memory." }, { "name": "takeStrings", - "documentation": "Consume and return unique strings referenced by this window." - }, - { - "name": "takeStyleDict", - "documentation": "Consume and return the unique global style-dictionary ids referenced by\nthis window, in local-index order; the host maps each to a `CellStyle`." - }, - { - "name": "takeStyleIndex", - "documentation": "Consume and return per-cell WINDOW-LOCAL style indices into the dict." + "documentation": "Consume formula-error sentinel strings referenced by the packed data." } ] } diff --git a/docs/test/compatibility-inventory.test.ts b/docs/test/compatibility-inventory.test.ts index 42ea0009..f7761016 100644 --- a/docs/test/compatibility-inventory.test.ts +++ b/docs/test/compatibility-inventory.test.ts @@ -29,7 +29,10 @@ const REQUIRED_RECORD_IDS = [ "xlsx.rich-workbook", "xlsx.shared-formulas", "validation.native-subset", - "xlsx.tables-charts-macros", + "table.native-subset", + "hyperlink.safe-subset", + "conditional-format.native-subset", + "xlsx.advanced-unsupported", "clipboard.delimited", "producer.microsoft-excel", "producer.google-sheets", diff --git a/package.json b/package.json index 30cdb9f5..9d83126a 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "scripts": { "api:report": "bun scripts/public-api.ts report", "api:check": "bun scripts/public-api.ts check", + "api:unused": "bun scripts/public-api.ts unused", "api:baseline": "bun scripts/public-api.ts baseline", "compatibility:validate": "bun scripts/conformance/validate-cli.ts", "compatibility:check": "bun scripts/conformance.ts offline", @@ -45,6 +46,7 @@ "size:report": "bun scripts/size-report.ts report", "size:history": "bun scripts/size-report.ts history", "size:record": "bun scripts/size-report.ts record", + "size:first-paint": "node test/bundler-fixtures/run.mjs && node test/bundler-fixtures/first-paint/run.mjs", "release:artifacts": "bun scripts/release-artifacts.ts build --output test-results/release-artifacts", "release:artifacts:verify": "bun scripts/release-artifacts.ts verify --artifacts test-results/release-artifacts", "release:prepare": "bun scripts/release-artifacts.ts prepare", @@ -62,6 +64,10 @@ "verify:nodenext-types": "tsc --noEmit -p test/consumer-nodenext", "examples": "bun run build:packages && bun run --filter '@sheetwrite/docs-start' dev", "toolchain:install-wasm-pack": "bun scripts/install-wasm-pack.ts", + "wasm:profile:build": "bun scripts/wasm-profile-matrix.ts build", + "wasm:profile:assemble": "bun scripts/wasm-profile-matrix.ts assemble", + "wasm:profile:decide": "bun scripts/wasm-profile-matrix.ts decide", + "wasm:store-only:probe": "bun scripts/wasm-profile-matrix.ts probe-store", "test:browser": "playwright test --config test/browser/playwright.config.ts", "browser:install:chromium": "playwright install --with-deps chromium", "test:browser:portability": "playwright test --config test/browser/playwright.config.ts --grep @portability", diff --git a/packages/core/package.json b/packages/core/package.json index 61af53aa..a30fab20 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -57,7 +57,8 @@ "./shell.css": "./shell.css" }, "files": [ - "dist", + "dist/**/*.js", + "dist/**/*.d.ts", "styles.css", "shell.css" ], diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 0b09c8f0..a488a218 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -107,8 +107,6 @@ export { resolveThemeFromCss, } from "./grid.js"; export { - cloneCellHyperlink, - createHyperlinkId, hyperlinkAt, isSafeExternalHyperlink, isValidCellHyperlink, diff --git a/packages/core/src/render-coordinator.ts b/packages/core/src/render-coordinator.ts index 2932ac55..65810db0 100644 --- a/packages/core/src/render-coordinator.ts +++ b/packages/core/src/render-coordinator.ts @@ -47,9 +47,23 @@ export class RenderCoordinator { private windowedColumnIndices: readonly number[] = []; private datasourceColumnIndices: readonly number[] = []; private datasourceFrozenColumns = -1; - private columnWindowSignature = ""; - private lastDataSignature = ""; - private lastPaintSignature = ""; + private columnWindowRevision = 0; + private lastDataSheet: SheetId | null = null; + private lastDataRowStart = 0; + private lastDataRowEnd = 0; + private lastDataColumnWindowRevision = 0; + private lastDataStoreEpoch = 0; + private lastDataInvalidationEpoch = 0; + private lastDataFrozenRows = 0; + private lastDataFrozenColumns = 0; + private lastPaintContentTop = 0; + private lastPaintScrollLeft = 0; + private lastPaintClientWidth = 0; + private lastPaintClientHeight = 0; + private lastPaintInvalidationEpoch = 0; + private lastPaintFrozenHeight = 0; + private lastPaintFrozenWidth = 0; + private lastPaintZoom = 0; private lastPaintView: VisibleWindowView | null = null; private cachedPaneViews: VisibleWindowView[] = []; private readonly paneValuePools: CellScalar[][] = []; @@ -57,7 +71,7 @@ export class RenderCoordinator { private readonly domMergeAnchorValuePools: CellScalar[][] = []; private readonly domMergeSourceViews: VisibleWindowView[] = []; private mainViewValuePool: CellScalar[] = []; - private domMergeAnchorSignature = ""; + private readonly domMergeAnchorState: Array = []; private destroyed = false; constructor(private readonly options: RenderCoordinatorOptions) {} @@ -82,7 +96,6 @@ export class RenderCoordinator { this.columnWindowEnd = -1; this.datasourceColumnIndices = []; this.datasourceFrozenColumns = -1; - this.columnWindowSignature = ""; this.invalidateData(); } @@ -117,9 +130,7 @@ export class RenderCoordinator { this.cachedPaneViews = []; this.cachedDomMergeAnchorViews = []; this.domMergeSourceViews.length = 0; - this.lastDataSignature = ""; - this.lastPaintSignature = ""; - this.domMergeAnchorSignature = ""; + this.domMergeAnchorState.length = 0; } private render(): void { @@ -172,10 +183,17 @@ export class RenderCoordinator { const columnWindowChanged = columns.start !== this.columnWindowStart || columns.end !== this.columnWindowEnd; if (columnWindowChanged) { + const columnCount = columns.end - columns.start; + let contentsChanged = columnCount !== this.windowedColumnIndices.length; + for (let offset = 0; !contentsChanged && offset < columnCount; offset++) { + if (geometry.columnIndices[columns.start + offset] !== this.windowedColumnIndices[offset]) { + contentsChanged = true; + } + } this.columnWindowStart = columns.start; this.columnWindowEnd = columns.end; this.windowedColumnIndices = geometry.columnIndices.slice(columns.start, columns.end); - this.columnWindowSignature = this.windowedColumnIndices.join(","); + if (contentsChanged) this.columnWindowRevision += 1; this.options.ariaMirror.bumpVersion(); } if (columnWindowChanged || frozenColumns !== this.datasourceFrozenColumns) { @@ -209,6 +227,7 @@ export class RenderCoordinator { this.datasourceColumnIndices, ); + const activeSheet = this.options.activeSheet(); const storeEpoch = this.options.storeEpoch(); const viewport: Viewport = { scrollTop: contentTop, @@ -223,18 +242,32 @@ export class RenderCoordinator { } this.options.renderer().setViewport(viewport); - const dataSignature = - `${this.options.activeSheet()}|${rows.start}|${rows.end}|${this.columnWindowSignature}` + - `|${storeEpoch}|${this.dataInvalidationEpoch}|${frozenRows}|${frozenColumns}`; - const paintSignature = - `${dataSignature}|${contentTop}|${scrollLeft}|${clientWidth}|${clientHeight}` + - `|${this.paintInvalidationEpoch}|${frozenHeight}|${frozenWidth}|${this.options.zoom()}`; - const refreshData = this.lastPaintView === null || dataSignature !== this.lastDataSignature; - const repaint = refreshData || paintSignature !== this.lastPaintSignature; + const zoom = this.options.zoom(); + const refreshData = + this.lastPaintView === null || + activeSheet !== this.lastDataSheet || + rows.start !== this.lastDataRowStart || + rows.end !== this.lastDataRowEnd || + this.columnWindowRevision !== this.lastDataColumnWindowRevision || + storeEpoch !== this.lastDataStoreEpoch || + this.dataInvalidationEpoch !== this.lastDataInvalidationEpoch || + frozenRows !== this.lastDataFrozenRows || + frozenColumns !== this.lastDataFrozenColumns; + const repaint = + refreshData || + !Object.is(contentTop, this.lastPaintContentTop) || + !Object.is(scrollLeft, this.lastPaintScrollLeft) || + !Object.is(clientWidth, this.lastPaintClientWidth) || + !Object.is(clientHeight, this.lastPaintClientHeight) || + this.paintInvalidationEpoch !== this.lastPaintInvalidationEpoch || + !Object.is(frozenHeight, this.lastPaintFrozenHeight) || + !Object.is(frozenWidth, this.lastPaintFrozenWidth) || + !Object.is(zoom, this.lastPaintZoom); let view: VisibleWindowView; if (usePanes) { view = this.paintFrozenPanes( + activeSheet, rows, this.windowedColumnIndices, frozenRows, @@ -253,7 +286,7 @@ export class RenderCoordinator { if (refreshData) { this.cachedPaneViews = []; this.lastPaintView = this.options.store.getVisibleWindow( - this.options.activeSheet(), + activeSheet, rows, this.windowedColumnIndices, ); @@ -275,8 +308,22 @@ export class RenderCoordinator { } } this.lastPaintView = view; - this.lastDataSignature = dataSignature; - this.lastPaintSignature = paintSignature; + this.lastDataSheet = activeSheet; + this.lastDataRowStart = rows.start; + this.lastDataRowEnd = rows.end; + this.lastDataColumnWindowRevision = this.columnWindowRevision; + this.lastDataStoreEpoch = storeEpoch; + this.lastDataInvalidationEpoch = this.dataInvalidationEpoch; + this.lastDataFrozenRows = frozenRows; + this.lastDataFrozenColumns = frozenColumns; + this.lastPaintContentTop = contentTop; + this.lastPaintScrollLeft = scrollLeft; + this.lastPaintClientWidth = clientWidth; + this.lastPaintClientHeight = clientHeight; + this.lastPaintInvalidationEpoch = this.paintInvalidationEpoch; + this.lastPaintFrozenHeight = frozenHeight; + this.lastPaintFrozenWidth = frozenWidth; + this.lastPaintZoom = zoom; this.options.ariaMirror.update(view); this.options.overlayPainter.paint(contentTop, scrollLeft, clientWidth, clientHeight); this.options.repositionEditor(contentTop, scrollLeft); @@ -291,6 +338,7 @@ export class RenderCoordinator { } private paintFrozenPanes( + activeSheet: SheetId, bodyRows: { start: number; end: number }, bodyColumns: readonly number[], frozenRows: number, @@ -324,7 +372,7 @@ export class RenderCoordinator { let view = this.cachedPaneViews[slot]; if (refreshData || !view) { view = this.retainPaneView( - this.options.store.getVisibleWindow(this.options.activeSheet(), rows, columns), + this.options.store.getVisibleWindow(activeSheet, rows, columns), slot, ); this.cachedPaneViews[slot] = view; @@ -400,14 +448,23 @@ export class RenderCoordinator { requests: readonly DomMergeAnchorRequest[], refreshData: boolean, ): boolean { - let signature = String(requests.length); + let changed = refreshData; + let stateIndex = 0; for (const request of requests) { - signature += `|${request.sheet}\u0000${request.row}`; - for (const col of request.cols) signature += `,${col}`; + if (this.domMergeAnchorState[stateIndex] !== request.sheet) changed = true; + this.domMergeAnchorState[stateIndex++] = request.sheet; + if (this.domMergeAnchorState[stateIndex] !== request.row) changed = true; + this.domMergeAnchorState[stateIndex++] = request.row; + if (this.domMergeAnchorState[stateIndex] !== request.cols.length) changed = true; + this.domMergeAnchorState[stateIndex++] = request.cols.length; + for (const col of request.cols) { + if (this.domMergeAnchorState[stateIndex] !== col) changed = true; + this.domMergeAnchorState[stateIndex++] = col; + } } - const refresh = refreshData || signature !== this.domMergeAnchorSignature; - this.domMergeAnchorSignature = signature; - return refresh; + if (this.domMergeAnchorState.length !== stateIndex) changed = true; + this.domMergeAnchorState.length = stateIndex; + return changed; } private loadDomMergeAnchorViews(requests: readonly DomMergeAnchorRequest[]): void { diff --git a/packages/core/src/store/data-engine.ts b/packages/core/src/store/data-engine.ts index 35e8d403..d1eb068a 100644 --- a/packages/core/src/store/data-engine.ts +++ b/packages/core/src/store/data-engine.ts @@ -26,11 +26,7 @@ import { type StoreMemoryBreakdown, type TransientResourcePeak, } from "../resource-accounting.js"; -import { - applySheetLifecycleOperation, - canAddSheetSnapshot, - createSheetLifecycleState, -} from "../sheet-lifecycle.js"; +import { canAddSheetSnapshot } from "../sheet-lifecycle.js"; import { validateSheetName } from "../sheet-name.js"; import { StyleDictionary } from "../style-dictionary.js"; import type { @@ -83,6 +79,7 @@ import { validWorkbookTable, workbookTableNameKey, } from "../workbook-table.js"; +import { PagedDirtyPreflight } from "./paged-dirty-preflight.js"; import { integerAt, mergeCrossesFreeze, @@ -257,16 +254,6 @@ export class IncompleteDataError extends SheetwriteError { this.capability = capability; } } -interface PagedDirtyPreflightState { - handle: number | null; - rows: number; - cols: number; - columnKeys: string[]; - dirty: number; - additional: number; - existing: Set | null; - seen: Set; -} export interface StoreDataEngineEffects { readonly appliedPatches: DocumentOp[]; @@ -278,15 +265,6 @@ function literalOf(value: CellScalar): CellValue { return { kind: "literal", value }; } -function cellKey(addr: CellAddress): string { - return JSON.stringify([addr.sheet, addr.row, addr.col]); -} - -function parseCellKey(key: string): CellAddress { - const [sheet, row, col] = JSON.parse(key) as [SheetId, number, number]; - return { sheet, row, col }; -} - /** * Owns the raw workbook/WASM state and returns typed effects to the public * transaction facade. It never owns public listeners, epochs, or policy. @@ -301,6 +279,7 @@ export class StoreDataEngine { private readonly view: StoreViewState; private readonly windowReader: StoreWindowReader; private readonly snapshotCodec: StoreSnapshotCodec; + private readonly pagedDirtyPreflight: PagedDirtyPreflight | null; private readonly boundaryAccounting = new BoundaryResourceAccounting(); private resourceOperation: RuntimeResourceOperation | null = "startup"; private disposed = false; @@ -349,6 +328,19 @@ export class StoreDataEngine { this.view = new StoreViewState(this.wasm, workbook, this.handles); this.windowReader = new StoreWindowReader(this.wasm, workbook, this.handles, this.styles); this.snapshotCodec = new StoreSnapshotCodec(workbook, this.windowReader, this.sheetIdsByHandle); + this.pagedDirtyPreflight = + options.storage === "paged" + ? new PagedDirtyPreflight( + this.wasm, + workbook, + this.handles, + this.sheetIdsByHandle, + this.rangeMutationStats, + this.boundaryAccounting, + options.dirtyCellLimit ?? DEFAULT_PAGED_DIRTY_CELL_LIMIT, + options.referenceSimulationLimit ?? MAX_PAGED_REFERENCE_SIMULATION_ENTRIES, + ) + : null; for (const sheet of workbook.sheets) { const handle = this.allocateSheet(sheet.columns.length, sheet.rowCount); this.boundaryAccounting.record( @@ -788,610 +780,7 @@ export class StoreDataEngine { } pagedDirtyCapacityIssue(patches: readonly DocumentOp[]): MutationIssue | null { - if (this.storageOptions.storage !== "paged") return null; - const limit = this.storageOptions.dirtyCellLimit ?? DEFAULT_PAGED_DIRTY_CELL_LIMIT; - const referenceSimulationLimit = - this.storageOptions.referenceSimulationLimit ?? MAX_PAGED_REFERENCE_SIMULATION_ENTRIES; - const wasmIndexLimit = 0xffff_ffff; - const states = new Map(); - const sheetLifecycle = createSheetLifecycleState(this.workbook.sheets); - const referenceLifecycle = createSheetLifecycleState(this.workbook.sheets); - const applicableRemove = patches.map((patch) => { - const lifecycle = applySheetLifecycleOperation(referenceLifecycle, patch); - return patch.op === "removeSheet" && lifecycle?.ok === true; - }); - const removeAfter = new Array(patches.length); - let laterRemove = false; - for (let index = patches.length - 1; index >= 0; index--) { - removeAfter[index] = laterRemove; - if (applicableRemove[index]) laterRemove = true; - } - let trackVirtualRefs = false; - let referenceSimulationExceeded = false; - let referenceSimulationActual = 0; - let virtualRefs: Map | null = null; - const referenceSimulationIssue = (): MutationIssue => ({ - kind: "resource-limit", - severity: "error", - resource: "paged-reference-simulation", - actual: referenceSimulationActual, - max: referenceSimulationLimit, - message: `Paged reference simulation exceeds the ${referenceSimulationLimit} entry limit`, - }); - const materializeVirtualRefs = (force = false): Map | null => { - if (!trackVirtualRefs && !force) return null; - if (virtualRefs) return virtualRefs; - const refs = new Map(); - for (const sheet of this.workbook.sheets) { - if (!this.handles.has(sheet.id) || sheet.rowCount === 0 || sheet.columns.length === 0) { - continue; - } - this.noteRangeMutationFfi(); - const snapshot = this.wasm.captureReferences( - this.handleOf(sheet.id), - referenceSimulationLimit - refs.size, - ); - if (!snapshot) { - referenceSimulationActual = referenceSimulationLimit + 1; - referenceSimulationExceeded = true; - return null; - } - const projection = consumeSourceSnapshot(snapshot, this.sheetIdsByHandle); - for (const [offset, target] of projection.references()) { - referenceSimulationActual++; - this.rangeMutationStats.admissionReferenceEntriesScanned++; - if (referenceSimulationActual > referenceSimulationLimit) { - referenceSimulationExceeded = true; - return null; - } - const source = { - sheet: sheet.id, - row: Math.floor(offset / sheet.columns.length), - col: offset % sheet.columns.length, - }; - refs.set(cellKey(source), target); - } - } - virtualRefs = refs; - this.rangeMutationStats.admissionReferenceMapsMaterialized++; - return virtualRefs; - }; - const setVirtualRef = (source: CellAddress, target: CellAddress | null): void => { - const refs = materializeVirtualRefs(); - if (!refs) return; - const key = cellKey(source); - const replaced = refs.delete(key); - if (target) { - if (!replaced && refs.size >= referenceSimulationLimit) { - referenceSimulationActual = refs.size + 1; - referenceSimulationExceeded = true; - return; - } - refs.set(key, { ...target }); - } - }; - const rebaseVirtualRefs = ( - sheet: SheetId, - rowAt: (row: number) => number | null, - colAt: (col: number) => number | null, - ): void => { - const refs = materializeVirtualRefs(); - if (!refs) return; - this.rangeMutationStats.admissionReferenceMapsMaterialized++; - const rebased = new Map(); - for (const [sourceKey, target] of refs) { - const source = parseCellKey(sourceKey); - const sourceRow = source.sheet === sheet ? rowAt(source.row) : source.row; - const sourceCol = source.sheet === sheet ? colAt(source.col) : source.col; - const targetRow = target.sheet === sheet ? rowAt(target.row) : target.row; - const targetCol = target.sheet === sheet ? colAt(target.col) : target.col; - if (sourceRow === null || sourceCol === null || targetRow === null || targetCol === null) { - continue; - } - const nextSource = { sheet: source.sheet, row: sourceRow, col: sourceCol }; - rebased.set(cellKey(nextSource), { - sheet: target.sheet, - row: targetRow, - col: targetCol, - }); - } - virtualRefs = rebased; - if (rebased.size > referenceSimulationLimit) { - referenceSimulationActual = rebased.size; - referenceSimulationExceeded = true; - } - }; - const clearVirtualRefs = ( - sheet: SheetId, - startRow: number, - startCol: number, - rows: number, - cols: number, - ): void => { - const refs = materializeVirtualRefs(); - if (!refs) return; - for (const sourceKey of refs.keys()) { - const source = parseCellKey(sourceKey); - if ( - source.sheet === sheet && - source.row >= startRow && - source.row < startRow + rows && - source.col >= startCol && - source.col < startCol + cols - ) { - refs.delete(sourceKey); - } - } - }; - const keyOf = (row: number, col: number) => `${row}:${col}`; - const stateFor = (sheet: SheetId) => { - const existing = states.get(sheet); - if (existing) return existing; - if (!this.handles.has(sheet)) return undefined; - const meta = this.sheetMeta(sheet); - const state: PagedDirtyPreflightState = { - handle: this.handleOf(sheet), - rows: meta.rowCount, - cols: meta.columns.length, - columnKeys: meta.columns.map((column) => column.key), - dirty: this.getPagedStats(sheet).dirtyCells, - additional: 0, - existing: null, - seen: new Set(), - }; - states.set(sheet, state); - return state; - }; - const issue = (actual: number): MutationIssue => ({ - kind: "resource-limit", - severity: "error", - resource: "paged-dirty-cells", - actual: Math.min(Number.MAX_SAFE_INTEGER, actual), - max: limit, - message: `Paged dirty cells exceed the ${limit} cell limit`, - }); - const invalid = (operationIndex: number, message: string): MutationIssue => ({ - kind: "invalid-operation", - severity: "error", - operationIndex, - message, - }); - const consume = (state: PagedDirtyPreflightState): MutationIssue | null => { - const actual = state.dirty + state.additional + 1; - if (actual > limit) return issue(actual); - state.additional += 1; - return null; - }; - const materializeExisting = (state: PagedDirtyPreflightState) => { - if (state.existing) return; - const existing = new Set(); - if (state.handle !== null) { - const coordinates = this.wasm.pagedDirtyCoordinates(state.handle); - for (let index = 0; index + 1 < coordinates.length; index += 2) { - existing.add(keyOf(coordinates[index]!, coordinates[index + 1]!)); - } - } - state.existing = existing; - state.dirty = existing.size; - }; - const rebaseSet = ( - source: ReadonlySet, - rowAt: (row: number) => number | null, - colAt: (col: number) => number | null, - ) => { - const rebased = new Set(); - for (const encoded of source) { - const separator = encoded.indexOf(":"); - const row = Number(encoded.slice(0, separator)); - const col = Number(encoded.slice(separator + 1)); - const nextRow = rowAt(row); - const nextCol = colAt(col); - if (nextRow !== null && nextCol !== null) rebased.add(keyOf(nextRow, nextCol)); - } - return rebased; - }; - const rebaseState = ( - state: PagedDirtyPreflightState, - rowAt: (row: number) => number | null, - colAt: (col: number) => number | null, - ) => { - materializeExisting(state); - state.existing = rebaseSet(state.existing!, rowAt, colAt); - state.seen = rebaseSet(state.seen, rowAt, colAt); - state.dirty = state.existing.size; - state.additional = state.seen.size; - }; - const addSparse = (sheet: SheetId, row: number, col: number): MutationIssue | null => { - const state = stateFor(sheet); - if (!state || row < 0 || col < 0 || row >= state.rows || col >= state.cols) return null; - const key = keyOf(row, col); - if (state.seen.has(key)) return null; - if ( - state.existing?.has(key) || - (state.existing === null && - state.handle !== null && - this.wasm.cellState(state.handle, row, col) === 3) - ) { - return null; - } - const rejection = consume(state); - if (!rejection) state.seen.add(key); - return rejection; - }; - const cellApplies = (sheet: SheetId, row: number, col: number): boolean => { - const state = stateFor(sheet); - return Boolean(state && row >= 0 && col >= 0 && row < state.rows && col < state.cols); - }; - const rectangleApplies = ( - sheet: SheetId, - startRow: number, - startCol: number, - rows: number, - cols: number, - ): boolean => { - const state = stateFor(sheet); - return Boolean( - state && - startRow >= 0 && - startCol >= 0 && - Number.isSafeInteger(rows) && - Number.isSafeInteger(cols) && - rows > 0 && - cols > 0 && - rows <= state.rows - startRow && - cols <= state.cols - startCol, - ); - }; - const addRectangle = ( - sheet: SheetId, - startRow: number, - startCol: number, - rows: number, - cols: number, - ): MutationIssue | null => { - if (rows === 1 && cols === 1) return addSparse(sheet, startRow, startCol); - const state = stateFor(sheet); - if (!state) return null; - if (startRow < 0 || startCol < 0) return null; - if ( - !Number.isSafeInteger(rows) || - !Number.isSafeInteger(cols) || - rows < 0 || - cols < 0 || - rows > Math.floor(Number.MAX_SAFE_INTEGER / Math.max(cols, 1)) - ) { - return issue(Number.MAX_SAFE_INTEGER); - } - if (rows > state.rows - startRow || cols > state.cols - startCol) { - const actual = state.dirty + state.additional + rows * cols; - return actual > limit ? issue(actual) : null; - } - materializeExisting(state); - for (let row = startRow; row < startRow + rows; row++) { - for (let col = startCol; col < startCol + cols; col++) { - const key = keyOf(row, col); - if (state.existing!.has(key) || state.seen.has(key)) continue; - const rejection = consume(state); - if (rejection) return rejection; - state.seen.add(key); - } - } - return null; - }; - - for (let operationIndex = 0; operationIndex < patches.length; operationIndex++) { - const patch = patches[operationIndex]!; - trackVirtualRefs = removeAfter[operationIndex] ?? false; - if (patch.op === "addSheet") { - if (applySheetLifecycleOperation(sheetLifecycle, patch)?.ok !== true) continue; - const snapshot = patch.sheet; - const state: PagedDirtyPreflightState = { - handle: null, - rows: snapshot.rowCount, - cols: snapshot.columns.length, - columnKeys: snapshot.columns.map((column) => column.key), - dirty: 0, - additional: 0, - existing: new Set(), - seen: new Set(), - }; - states.set(snapshot.id, state); - for (const block of snapshot.cells) { - for (const cell of block.cells) { - const rejection = addSparse( - snapshot.id, - block.startRow + cell.rowOffset, - block.startCol + cell.colOffset, - ); - if (rejection) return rejection; - if (cell.value.kind === "ref") { - setVirtualRef( - { - sheet: snapshot.id, - row: block.startRow + cell.rowOffset, - col: block.startCol + cell.colOffset, - }, - cell.value.target, - ); - if (referenceSimulationExceeded) return referenceSimulationIssue(); - } - } - } - if (referenceSimulationExceeded) return referenceSimulationIssue(); - continue; - } - if (patch.op === "removeSheet") { - if (applySheetLifecycleOperation(sheetLifecycle, patch)?.ok !== true) continue; - if (referenceSimulationExceeded) return referenceSimulationIssue(); - if (!trackVirtualRefs && virtualRefs === null) { - this.noteRangeMutationFfi(); - const sources = this.wasm.referencesTargeting( - this.handleOf(patch.sheet), - referenceSimulationLimit, - ); - if (!sources) { - referenceSimulationActual = referenceSimulationLimit + 1; - return referenceSimulationIssue(); - } - this.rangeMutationStats.admissionReferenceEntriesScanned += sources.length / 3; - for (let index = 0; index < sources.length; index += 3) { - const sourceSheet = this.sheetIdsByHandle[sources[index]!]; - if (sourceSheet === undefined || sourceSheet === patch.sheet) continue; - const rejection = addSparse(sourceSheet, sources[index + 1]!, sources[index + 2]!); - if (rejection) return rejection; - } - states.delete(patch.sheet); - continue; - } - const materializedRefs = materializeVirtualRefs(true); - if (!materializedRefs) return referenceSimulationIssue(); - const entries = (function* (): IterableIterator<[CellAddress, CellAddress]> { - for (const [sourceKey, target] of materializedRefs) { - yield [parseCellKey(sourceKey), target]; - } - })(); - const remaining = trackVirtualRefs ? new Map() : null; - for (const [source, target] of entries) { - if (source.sheet !== patch.sheet && target.sheet === patch.sheet) { - const rejection = addSparse(source.sheet, source.row, source.col); - if (rejection) return rejection; - } - if (remaining && source.sheet !== patch.sheet && target.sheet !== patch.sheet) { - remaining.set(cellKey(source), { ...target }); - } - } - virtualRefs = remaining; - states.delete(patch.sheet); - continue; - } - if ( - patch.op === "renameSheet" || - patch.op === "moveSheet" || - patch.op === "setSheetVisibility" - ) { - applySheetLifecycleOperation(sheetLifecycle, patch); - continue; - } - const sheet = patchSheetId(patch); - if ( - sheet !== null && - (patch.op === "addRows" || - patch.op === "removeRows" || - patch.op === "moveRows" || - patch.op === "addColumns" || - patch.op === "removeColumns" || - patch.op === "moveColumns") - ) { - const state = stateFor(sheet); - if (!state) continue; - const keep = (index: number) => index; - if (patch.op === "addRows") { - if (patch.at > state.rows || patch.count > wasmIndexLimit - state.rows) { - return invalid(operationIndex, "addRows exceeds the current sheet bounds"); - } - rebaseState(state, (row) => (row >= patch.at ? row + patch.count : row), keep); - rebaseVirtualRefs(sheet, (row) => (row >= patch.at ? row + patch.count : row), keep); - state.rows += patch.count; - } else if (patch.op === "removeRows") { - if (patch.at > state.rows || patch.count > state.rows - patch.at) { - return invalid(operationIndex, "removeRows exceeds the current sheet bounds"); - } - rebaseState( - state, - (row) => - row < patch.at ? row : row < patch.at + patch.count ? null : row - patch.count, - keep, - ); - rebaseVirtualRefs( - sheet, - (row) => - row < patch.at ? row : row < patch.at + patch.count ? null : row - patch.count, - keep, - ); - state.rows -= patch.count; - } else if (patch.op === "moveRows") { - if ( - patch.from > state.rows || - patch.count > state.rows - patch.from || - patch.to > state.rows - patch.count - ) { - return invalid(operationIndex, "moveRows exceeds the current sheet bounds"); - } - rebaseState(state, (row) => moveIndex(row, patch.from, patch.count, patch.to), keep); - rebaseVirtualRefs( - sheet, - (row) => moveIndex(row, patch.from, patch.count, patch.to), - keep, - ); - } else if (patch.op === "addColumns") { - const insertedKeys = patch.columns.map((column) => column.key); - if ( - patch.at > state.cols || - insertedKeys.length === 0 || - insertedKeys.length > wasmIndexLimit - state.cols || - new Set([...state.columnKeys, ...insertedKeys]).size !== - state.columnKeys.length + insertedKeys.length - ) { - return invalid(operationIndex, "addColumns exceeds the current sheet bounds"); - } - rebaseState(state, keep, (col) => (col >= patch.at ? col + patch.columns.length : col)); - rebaseVirtualRefs(sheet, keep, (col) => - col >= patch.at ? col + patch.columns.length : col, - ); - state.cols += patch.columns.length; - state.columnKeys.splice(patch.at, 0, ...insertedKeys); - } else if (patch.op === "removeColumns") { - if ( - patch.at > state.cols || - patch.count > state.cols - patch.at || - patch.count === state.cols - ) { - return invalid(operationIndex, "removeColumns exceeds the current sheet bounds"); - } - rebaseState(state, keep, (col) => - col < patch.at ? col : col < patch.at + patch.count ? null : col - patch.count, - ); - rebaseVirtualRefs(sheet, keep, (col) => - col < patch.at ? col : col < patch.at + patch.count ? null : col - patch.count, - ); - state.cols -= patch.count; - state.columnKeys.splice(patch.at, patch.count); - } else { - if ( - patch.from > state.cols || - patch.count > state.cols - patch.from || - patch.to > state.cols - patch.count - ) { - return invalid(operationIndex, "moveColumns exceeds the current sheet bounds"); - } - rebaseState(state, keep, (col) => moveIndex(col, patch.from, patch.count, patch.to)); - rebaseVirtualRefs(sheet, keep, (col) => - moveIndex(col, patch.from, patch.count, patch.to), - ); - const movedKeys = state.columnKeys.splice(patch.from, patch.count); - state.columnKeys.splice(patch.to, 0, ...movedKeys); - } - if (referenceSimulationExceeded) return referenceSimulationIssue(); - continue; - } - let rejection: MutationIssue | null = null; - if (patch.op === "set") { - if (!cellApplies(patch.addr.sheet, patch.addr.row, patch.addr.col)) continue; - rejection = addSparse(patch.addr.sheet, patch.addr.row, patch.addr.col); - if (!rejection) { - setVirtualRef(patch.addr, patch.value.kind === "ref" ? patch.value.target : null); - } - } else if (patch.op === "setRange") { - const range = normalizedRange(patch.range); - const rows = range.end.row - range.start.row + 1; - const cols = range.end.col - range.start.col + 1; - if ( - !rectangleApplies(range.sheet, range.start.row, range.start.col, rows, cols) || - patch.cells.some( - (cell) => - !integerAt(cell.rowOffset) || - !integerAt(cell.colOffset) || - cell.rowOffset >= rows || - cell.colOffset >= cols, - ) - ) { - continue; - } - for (const cell of patch.cells) { - rejection = addSparse( - range.sheet, - range.start.row + cell.rowOffset, - range.start.col + cell.colOffset, - ); - if (rejection) break; - const source = { - sheet: range.sheet, - row: range.start.row + cell.rowOffset, - col: range.start.col + cell.colOffset, - }; - setVirtualRef(source, cell.value.kind === "ref" ? cell.value.target : null); - if (referenceSimulationExceeded) return referenceSimulationIssue(); - } - } else if (patch.op === "setBlock") { - const range = normalizedRange(patch.range); - rejection = addRectangle( - range.sheet, - range.start.row, - range.start.col, - patch.block.rowCount, - patch.block.colCount, - ); - if ( - !rejection && - patch.block.rowCount === range.end.row - range.start.row + 1 && - patch.block.colCount === range.end.col - range.start.col + 1 && - rectangleApplies( - range.sheet, - range.start.row, - range.start.col, - patch.block.rowCount, - patch.block.colCount, - ) - ) { - for (let rowOffset = 0; rowOffset < patch.block.rowCount; rowOffset++) { - for (let colOffset = 0; colOffset < patch.block.colCount; colOffset++) { - setVirtualRef( - { - sheet: range.sheet, - row: range.start.row + rowOffset, - col: range.start.col + colOffset, - }, - null, - ); - if (referenceSimulationExceeded) return referenceSimulationIssue(); - } - } - for (const [offset, target] of patch.block.refs ?? []) { - setVirtualRef( - { - sheet: range.sheet, - row: range.start.row + Math.floor(offset / patch.block.colCount), - col: range.start.col + (offset % patch.block.colCount), - }, - target, - ); - if (referenceSimulationExceeded) return referenceSimulationIssue(); - } - } - } else if (patch.op === "setRangeStyle" || patch.op === "clearRange") { - const range = normalizedRange(patch.range); - rejection = addRectangle( - range.sheet, - range.start.row, - range.start.col, - range.end.row - range.start.row + 1, - range.end.col - range.start.col + 1, - ); - if ( - !rejection && - patch.op === "clearRange" && - (patch.contents ?? true) && - rectangleApplies( - range.sheet, - range.start.row, - range.start.col, - range.end.row - range.start.row + 1, - range.end.col - range.start.col + 1, - ) - ) { - clearVirtualRefs( - range.sheet, - range.start.row, - range.start.col, - range.end.row - range.start.row + 1, - range.end.col - range.start.col + 1, - ); - } - } - if (referenceSimulationExceeded) return referenceSimulationIssue(); - if (rejection) return rejection; - } - return null; + return this.pagedDirtyPreflight?.issue(patches, this.resourceOperation) ?? null; } private requireCompleteQuery(sheet: SheetId): void { diff --git a/packages/core/src/store/paged-dirty-preflight.ts b/packages/core/src/store/paged-dirty-preflight.ts new file mode 100644 index 00000000..0e773fbd --- /dev/null +++ b/packages/core/src/store/paged-dirty-preflight.ts @@ -0,0 +1,672 @@ +import { consumeSourceSnapshot } from "../reference.js"; +import type { + BoundaryResourceAccounting, + RuntimeResourceOperation, +} from "../resource-accounting.js"; +import { applySheetLifecycleOperation, createSheetLifecycleState } from "../sheet-lifecycle.js"; +import type { CellAddress, SheetId } from "../types/coordinates.js"; +import type { DocumentOp, MutationIssue, Workbook } from "../types/document.js"; +import { integerAt, moveIndex, normalizedRange, patchSheetId } from "./ranges.js"; +import type { RecomputingCellStore } from "./wasm-contract.js"; + +export interface PagedDirtyPreflightStats { + ffiCalls: number; + maxTransferredArrayLength: number; + admissionReferenceEntriesScanned: number; + admissionReferenceMapsMaterialized: number; +} + +interface PagedDirtyPreflightState { + handle: number | null; + rows: number; + cols: number; + columnKeys: string[]; + dirty: number; + additional: number; + existing: Set | null; + seen: Set; +} + +function cellKey(addr: CellAddress): string { + return JSON.stringify([addr.sheet, addr.row, addr.col]); +} + +function parseCellKey(key: string): CellAddress { + const [sheet, row, col] = JSON.parse(key) as [SheetId, number, number]; + return { sheet, row, col }; +} + +/** Simulates paged dirty-cell and reference admission before a transaction mutates the store. */ +export class PagedDirtyPreflight { + constructor( + private readonly wasm: RecomputingCellStore, + private readonly workbook: Workbook, + private readonly handles: ReadonlyMap, + private readonly sheetIdsByHandle: readonly SheetId[], + private readonly stats: PagedDirtyPreflightStats, + private readonly boundaryAccounting: BoundaryResourceAccounting, + private readonly dirtyCellLimit: number, + private readonly referenceSimulationLimit: number, + ) {} + + private handleOf(sheet: SheetId): number { + const handle = this.handles.get(sheet); + if (handle === undefined) throw new Error(`unknown sheet: ${sheet}`); + return handle; + } + + private noteRangeMutationFfi(operation: RuntimeResourceOperation | null): void { + this.stats.ffiCalls += 1; + this.stats.maxTransferredArrayLength = Math.max(this.stats.maxTransferredArrayLength, 0); + this.boundaryAccounting.record(operation ?? "edit", "js-to-wasm", 0, "scalar"); + } + + issue( + patches: readonly DocumentOp[], + resourceOperation: RuntimeResourceOperation | null, + ): MutationIssue | null { + const limit = this.dirtyCellLimit; + const referenceSimulationLimit = this.referenceSimulationLimit; + const wasmIndexLimit = 0xffff_ffff; + const states = new Map(); + const sheetLifecycle = createSheetLifecycleState(this.workbook.sheets); + const referenceLifecycle = createSheetLifecycleState(this.workbook.sheets); + const applicableRemove = patches.map((patch) => { + const lifecycle = applySheetLifecycleOperation(referenceLifecycle, patch); + return patch.op === "removeSheet" && lifecycle?.ok === true; + }); + const removeAfter = new Array(patches.length); + let laterRemove = false; + for (let index = patches.length - 1; index >= 0; index--) { + removeAfter[index] = laterRemove; + if (applicableRemove[index]) laterRemove = true; + } + let trackVirtualRefs = false; + let referenceSimulationExceeded = false; + let referenceSimulationActual = 0; + let virtualRefs: Map | null = null; + const referenceSimulationIssue = (): MutationIssue => ({ + kind: "resource-limit", + severity: "error", + resource: "paged-reference-simulation", + actual: referenceSimulationActual, + max: referenceSimulationLimit, + message: `Paged reference simulation exceeds the ${referenceSimulationLimit} entry limit`, + }); + const materializeVirtualRefs = (force = false): Map | null => { + if (!trackVirtualRefs && !force) return null; + if (virtualRefs) return virtualRefs; + const refs = new Map(); + for (const sheet of this.workbook.sheets) { + if (!this.handles.has(sheet.id) || sheet.rowCount === 0 || sheet.columns.length === 0) { + continue; + } + this.noteRangeMutationFfi(resourceOperation); + const snapshot = this.wasm.captureReferences( + this.handleOf(sheet.id), + referenceSimulationLimit - refs.size, + ); + if (!snapshot) { + referenceSimulationActual = referenceSimulationLimit + 1; + referenceSimulationExceeded = true; + return null; + } + const projection = consumeSourceSnapshot(snapshot, this.sheetIdsByHandle); + for (const [offset, target] of projection.references()) { + referenceSimulationActual++; + this.stats.admissionReferenceEntriesScanned++; + if (referenceSimulationActual > referenceSimulationLimit) { + referenceSimulationExceeded = true; + return null; + } + const source = { + sheet: sheet.id, + row: Math.floor(offset / sheet.columns.length), + col: offset % sheet.columns.length, + }; + refs.set(cellKey(source), target); + } + } + virtualRefs = refs; + this.stats.admissionReferenceMapsMaterialized++; + return virtualRefs; + }; + const setVirtualRef = (source: CellAddress, target: CellAddress | null): void => { + const refs = materializeVirtualRefs(); + if (!refs) return; + const key = cellKey(source); + const replaced = refs.delete(key); + if (target) { + if (!replaced && refs.size >= referenceSimulationLimit) { + referenceSimulationActual = refs.size + 1; + referenceSimulationExceeded = true; + return; + } + refs.set(key, { ...target }); + } + }; + const rebaseVirtualRefs = ( + sheet: SheetId, + rowAt: (row: number) => number | null, + colAt: (col: number) => number | null, + ): void => { + const refs = materializeVirtualRefs(); + if (!refs) return; + this.stats.admissionReferenceMapsMaterialized++; + const rebased = new Map(); + for (const [sourceKey, target] of refs) { + const source = parseCellKey(sourceKey); + const sourceRow = source.sheet === sheet ? rowAt(source.row) : source.row; + const sourceCol = source.sheet === sheet ? colAt(source.col) : source.col; + const targetRow = target.sheet === sheet ? rowAt(target.row) : target.row; + const targetCol = target.sheet === sheet ? colAt(target.col) : target.col; + if (sourceRow === null || sourceCol === null || targetRow === null || targetCol === null) { + continue; + } + const nextSource = { sheet: source.sheet, row: sourceRow, col: sourceCol }; + rebased.set(cellKey(nextSource), { + sheet: target.sheet, + row: targetRow, + col: targetCol, + }); + } + virtualRefs = rebased; + if (rebased.size > referenceSimulationLimit) { + referenceSimulationActual = rebased.size; + referenceSimulationExceeded = true; + } + }; + const clearVirtualRefs = ( + sheet: SheetId, + startRow: number, + startCol: number, + rows: number, + cols: number, + ): void => { + const refs = materializeVirtualRefs(); + if (!refs) return; + for (const sourceKey of refs.keys()) { + const source = parseCellKey(sourceKey); + if ( + source.sheet === sheet && + source.row >= startRow && + source.row < startRow + rows && + source.col >= startCol && + source.col < startCol + cols + ) { + refs.delete(sourceKey); + } + } + }; + const keyOf = (row: number, col: number) => `${row}:${col}`; + const stateFor = (sheet: SheetId) => { + const existing = states.get(sheet); + if (existing) return existing; + if (!this.handles.has(sheet)) return undefined; + const meta = this.workbook.sheets.find((candidate) => candidate.id === sheet); + if (!meta) throw new Error(`unknown sheet: ${sheet}`); + const state: PagedDirtyPreflightState = { + handle: this.handleOf(sheet), + rows: meta.rowCount, + cols: meta.columns.length, + columnKeys: meta.columns.map((column) => column.key), + dirty: this.wasm.pagedStats(this.handleOf(sheet))[2] ?? 0, + additional: 0, + existing: null, + seen: new Set(), + }; + states.set(sheet, state); + return state; + }; + const issue = (actual: number): MutationIssue => ({ + kind: "resource-limit", + severity: "error", + resource: "paged-dirty-cells", + actual: Math.min(Number.MAX_SAFE_INTEGER, actual), + max: limit, + message: `Paged dirty cells exceed the ${limit} cell limit`, + }); + const invalid = (operationIndex: number, message: string): MutationIssue => ({ + kind: "invalid-operation", + severity: "error", + operationIndex, + message, + }); + const consume = (state: PagedDirtyPreflightState): MutationIssue | null => { + const actual = state.dirty + state.additional + 1; + if (actual > limit) return issue(actual); + state.additional += 1; + return null; + }; + const materializeExisting = (state: PagedDirtyPreflightState) => { + if (state.existing) return; + const existing = new Set(); + if (state.handle !== null) { + const coordinates = this.wasm.pagedDirtyCoordinates(state.handle); + for (let index = 0; index + 1 < coordinates.length; index += 2) { + existing.add(keyOf(coordinates[index]!, coordinates[index + 1]!)); + } + } + state.existing = existing; + state.dirty = existing.size; + }; + const rebaseSet = ( + source: ReadonlySet, + rowAt: (row: number) => number | null, + colAt: (col: number) => number | null, + ) => { + const rebased = new Set(); + for (const encoded of source) { + const separator = encoded.indexOf(":"); + const row = Number(encoded.slice(0, separator)); + const col = Number(encoded.slice(separator + 1)); + const nextRow = rowAt(row); + const nextCol = colAt(col); + if (nextRow !== null && nextCol !== null) rebased.add(keyOf(nextRow, nextCol)); + } + return rebased; + }; + const rebaseState = ( + state: PagedDirtyPreflightState, + rowAt: (row: number) => number | null, + colAt: (col: number) => number | null, + ) => { + materializeExisting(state); + state.existing = rebaseSet(state.existing!, rowAt, colAt); + state.seen = rebaseSet(state.seen, rowAt, colAt); + state.dirty = state.existing.size; + state.additional = state.seen.size; + }; + const addSparse = (sheet: SheetId, row: number, col: number): MutationIssue | null => { + const state = stateFor(sheet); + if (!state || row < 0 || col < 0 || row >= state.rows || col >= state.cols) return null; + const key = keyOf(row, col); + if (state.seen.has(key)) return null; + if ( + state.existing?.has(key) || + (state.existing === null && + state.handle !== null && + this.wasm.cellState(state.handle, row, col) === 3) + ) { + return null; + } + const rejection = consume(state); + if (!rejection) state.seen.add(key); + return rejection; + }; + const cellApplies = (sheet: SheetId, row: number, col: number): boolean => { + const state = stateFor(sheet); + return Boolean(state && row >= 0 && col >= 0 && row < state.rows && col < state.cols); + }; + const rectangleApplies = ( + sheet: SheetId, + startRow: number, + startCol: number, + rows: number, + cols: number, + ): boolean => { + const state = stateFor(sheet); + return Boolean( + state && + startRow >= 0 && + startCol >= 0 && + Number.isSafeInteger(rows) && + Number.isSafeInteger(cols) && + rows > 0 && + cols > 0 && + rows <= state.rows - startRow && + cols <= state.cols - startCol, + ); + }; + const addRectangle = ( + sheet: SheetId, + startRow: number, + startCol: number, + rows: number, + cols: number, + ): MutationIssue | null => { + if (rows === 1 && cols === 1) return addSparse(sheet, startRow, startCol); + const state = stateFor(sheet); + if (!state) return null; + if (startRow < 0 || startCol < 0) return null; + if ( + !Number.isSafeInteger(rows) || + !Number.isSafeInteger(cols) || + rows < 0 || + cols < 0 || + rows > Math.floor(Number.MAX_SAFE_INTEGER / Math.max(cols, 1)) + ) { + return issue(Number.MAX_SAFE_INTEGER); + } + if (rows > state.rows - startRow || cols > state.cols - startCol) { + const actual = state.dirty + state.additional + rows * cols; + return actual > limit ? issue(actual) : null; + } + materializeExisting(state); + for (let row = startRow; row < startRow + rows; row++) { + for (let col = startCol; col < startCol + cols; col++) { + const key = keyOf(row, col); + if (state.existing!.has(key) || state.seen.has(key)) continue; + const rejection = consume(state); + if (rejection) return rejection; + state.seen.add(key); + } + } + return null; + }; + + for (let operationIndex = 0; operationIndex < patches.length; operationIndex++) { + const patch = patches[operationIndex]!; + trackVirtualRefs = removeAfter[operationIndex] ?? false; + if (patch.op === "addSheet") { + if (applySheetLifecycleOperation(sheetLifecycle, patch)?.ok !== true) continue; + const snapshot = patch.sheet; + const state: PagedDirtyPreflightState = { + handle: null, + rows: snapshot.rowCount, + cols: snapshot.columns.length, + columnKeys: snapshot.columns.map((column) => column.key), + dirty: 0, + additional: 0, + existing: new Set(), + seen: new Set(), + }; + states.set(snapshot.id, state); + for (const block of snapshot.cells) { + for (const cell of block.cells) { + const rejection = addSparse( + snapshot.id, + block.startRow + cell.rowOffset, + block.startCol + cell.colOffset, + ); + if (rejection) return rejection; + if (cell.value.kind === "ref") { + setVirtualRef( + { + sheet: snapshot.id, + row: block.startRow + cell.rowOffset, + col: block.startCol + cell.colOffset, + }, + cell.value.target, + ); + if (referenceSimulationExceeded) return referenceSimulationIssue(); + } + } + } + if (referenceSimulationExceeded) return referenceSimulationIssue(); + continue; + } + if (patch.op === "removeSheet") { + if (applySheetLifecycleOperation(sheetLifecycle, patch)?.ok !== true) continue; + if (referenceSimulationExceeded) return referenceSimulationIssue(); + if (!trackVirtualRefs && virtualRefs === null) { + this.noteRangeMutationFfi(resourceOperation); + const sources = this.wasm.referencesTargeting( + this.handleOf(patch.sheet), + referenceSimulationLimit, + ); + if (!sources) { + referenceSimulationActual = referenceSimulationLimit + 1; + return referenceSimulationIssue(); + } + this.stats.admissionReferenceEntriesScanned += sources.length / 3; + for (let index = 0; index < sources.length; index += 3) { + const sourceSheet = this.sheetIdsByHandle[sources[index]!]; + if (sourceSheet === undefined || sourceSheet === patch.sheet) continue; + const rejection = addSparse(sourceSheet, sources[index + 1]!, sources[index + 2]!); + if (rejection) return rejection; + } + states.delete(patch.sheet); + continue; + } + const materializedRefs = materializeVirtualRefs(true); + if (!materializedRefs) return referenceSimulationIssue(); + const entries = (function* (): IterableIterator<[CellAddress, CellAddress]> { + for (const [sourceKey, target] of materializedRefs) { + yield [parseCellKey(sourceKey), target]; + } + })(); + const remaining = trackVirtualRefs ? new Map() : null; + for (const [source, target] of entries) { + if (source.sheet !== patch.sheet && target.sheet === patch.sheet) { + const rejection = addSparse(source.sheet, source.row, source.col); + if (rejection) return rejection; + } + if (remaining && source.sheet !== patch.sheet && target.sheet !== patch.sheet) { + remaining.set(cellKey(source), { ...target }); + } + } + virtualRefs = remaining; + states.delete(patch.sheet); + continue; + } + if ( + patch.op === "renameSheet" || + patch.op === "moveSheet" || + patch.op === "setSheetVisibility" + ) { + applySheetLifecycleOperation(sheetLifecycle, patch); + continue; + } + const sheet = patchSheetId(patch); + if ( + sheet !== null && + (patch.op === "addRows" || + patch.op === "removeRows" || + patch.op === "moveRows" || + patch.op === "addColumns" || + patch.op === "removeColumns" || + patch.op === "moveColumns") + ) { + const state = stateFor(sheet); + if (!state) continue; + const keep = (index: number) => index; + if (patch.op === "addRows") { + if (patch.at > state.rows || patch.count > wasmIndexLimit - state.rows) { + return invalid(operationIndex, "addRows exceeds the current sheet bounds"); + } + rebaseState(state, (row) => (row >= patch.at ? row + patch.count : row), keep); + rebaseVirtualRefs(sheet, (row) => (row >= patch.at ? row + patch.count : row), keep); + state.rows += patch.count; + } else if (patch.op === "removeRows") { + if (patch.at > state.rows || patch.count > state.rows - patch.at) { + return invalid(operationIndex, "removeRows exceeds the current sheet bounds"); + } + rebaseState( + state, + (row) => + row < patch.at ? row : row < patch.at + patch.count ? null : row - patch.count, + keep, + ); + rebaseVirtualRefs( + sheet, + (row) => + row < patch.at ? row : row < patch.at + patch.count ? null : row - patch.count, + keep, + ); + state.rows -= patch.count; + } else if (patch.op === "moveRows") { + if ( + patch.from > state.rows || + patch.count > state.rows - patch.from || + patch.to > state.rows - patch.count + ) { + return invalid(operationIndex, "moveRows exceeds the current sheet bounds"); + } + rebaseState(state, (row) => moveIndex(row, patch.from, patch.count, patch.to), keep); + rebaseVirtualRefs( + sheet, + (row) => moveIndex(row, patch.from, patch.count, patch.to), + keep, + ); + } else if (patch.op === "addColumns") { + const insertedKeys = patch.columns.map((column) => column.key); + if ( + patch.at > state.cols || + insertedKeys.length === 0 || + insertedKeys.length > wasmIndexLimit - state.cols || + new Set([...state.columnKeys, ...insertedKeys]).size !== + state.columnKeys.length + insertedKeys.length + ) { + return invalid(operationIndex, "addColumns exceeds the current sheet bounds"); + } + rebaseState(state, keep, (col) => (col >= patch.at ? col + patch.columns.length : col)); + rebaseVirtualRefs(sheet, keep, (col) => + col >= patch.at ? col + patch.columns.length : col, + ); + state.cols += patch.columns.length; + state.columnKeys.splice(patch.at, 0, ...insertedKeys); + } else if (patch.op === "removeColumns") { + if ( + patch.at > state.cols || + patch.count > state.cols - patch.at || + patch.count === state.cols + ) { + return invalid(operationIndex, "removeColumns exceeds the current sheet bounds"); + } + rebaseState(state, keep, (col) => + col < patch.at ? col : col < patch.at + patch.count ? null : col - patch.count, + ); + rebaseVirtualRefs(sheet, keep, (col) => + col < patch.at ? col : col < patch.at + patch.count ? null : col - patch.count, + ); + state.cols -= patch.count; + state.columnKeys.splice(patch.at, patch.count); + } else { + if ( + patch.from > state.cols || + patch.count > state.cols - patch.from || + patch.to > state.cols - patch.count + ) { + return invalid(operationIndex, "moveColumns exceeds the current sheet bounds"); + } + rebaseState(state, keep, (col) => moveIndex(col, patch.from, patch.count, patch.to)); + rebaseVirtualRefs(sheet, keep, (col) => + moveIndex(col, patch.from, patch.count, patch.to), + ); + const movedKeys = state.columnKeys.splice(patch.from, patch.count); + state.columnKeys.splice(patch.to, 0, ...movedKeys); + } + if (referenceSimulationExceeded) return referenceSimulationIssue(); + continue; + } + let rejection: MutationIssue | null = null; + if (patch.op === "set") { + if (!cellApplies(patch.addr.sheet, patch.addr.row, patch.addr.col)) continue; + rejection = addSparse(patch.addr.sheet, patch.addr.row, patch.addr.col); + if (!rejection) { + setVirtualRef(patch.addr, patch.value.kind === "ref" ? patch.value.target : null); + } + } else if (patch.op === "setRange") { + const range = normalizedRange(patch.range); + const rows = range.end.row - range.start.row + 1; + const cols = range.end.col - range.start.col + 1; + if ( + !rectangleApplies(range.sheet, range.start.row, range.start.col, rows, cols) || + patch.cells.some( + (cell) => + !integerAt(cell.rowOffset) || + !integerAt(cell.colOffset) || + cell.rowOffset >= rows || + cell.colOffset >= cols, + ) + ) { + continue; + } + for (const cell of patch.cells) { + rejection = addSparse( + range.sheet, + range.start.row + cell.rowOffset, + range.start.col + cell.colOffset, + ); + if (rejection) break; + const source = { + sheet: range.sheet, + row: range.start.row + cell.rowOffset, + col: range.start.col + cell.colOffset, + }; + setVirtualRef(source, cell.value.kind === "ref" ? cell.value.target : null); + if (referenceSimulationExceeded) return referenceSimulationIssue(); + } + } else if (patch.op === "setBlock") { + const range = normalizedRange(patch.range); + rejection = addRectangle( + range.sheet, + range.start.row, + range.start.col, + patch.block.rowCount, + patch.block.colCount, + ); + if ( + !rejection && + patch.block.rowCount === range.end.row - range.start.row + 1 && + patch.block.colCount === range.end.col - range.start.col + 1 && + rectangleApplies( + range.sheet, + range.start.row, + range.start.col, + patch.block.rowCount, + patch.block.colCount, + ) + ) { + for (let rowOffset = 0; rowOffset < patch.block.rowCount; rowOffset++) { + for (let colOffset = 0; colOffset < patch.block.colCount; colOffset++) { + setVirtualRef( + { + sheet: range.sheet, + row: range.start.row + rowOffset, + col: range.start.col + colOffset, + }, + null, + ); + if (referenceSimulationExceeded) return referenceSimulationIssue(); + } + } + for (const [offset, target] of patch.block.refs ?? []) { + setVirtualRef( + { + sheet: range.sheet, + row: range.start.row + Math.floor(offset / patch.block.colCount), + col: range.start.col + (offset % patch.block.colCount), + }, + target, + ); + if (referenceSimulationExceeded) return referenceSimulationIssue(); + } + } + } else if (patch.op === "setRangeStyle" || patch.op === "clearRange") { + const range = normalizedRange(patch.range); + rejection = addRectangle( + range.sheet, + range.start.row, + range.start.col, + range.end.row - range.start.row + 1, + range.end.col - range.start.col + 1, + ); + if ( + !rejection && + patch.op === "clearRange" && + (patch.contents ?? true) && + rectangleApplies( + range.sheet, + range.start.row, + range.start.col, + range.end.row - range.start.row + 1, + range.end.col - range.start.col + 1, + ) + ) { + clearVirtualRefs( + range.sheet, + range.start.row, + range.start.col, + range.end.row - range.start.row + 1, + range.end.col - range.start.col + 1, + ); + } + } + if (referenceSimulationExceeded) return referenceSimulationIssue(); + if (rejection) return rejection; + } + return null; + } +} diff --git a/packages/core/src/store/wasm-contract.ts b/packages/core/src/store/wasm-contract.ts index ee09748d..28949fe7 100644 --- a/packages/core/src/store/wasm-contract.ts +++ b/packages/core/src/store/wasm-contract.ts @@ -195,14 +195,8 @@ export type RecomputingCellStore = CellStore & { markCellCleanRevision(sheet: number, row: number, col: number, revision: bigint): boolean; }; -/** Consuming accessors transfer packed arrays out of one WASM window exactly once. */ +/** Consuming accessors transfer one packed numeric payload and any strings once. */ export type ConsumingWindowView = WindowView & { - takeKinds(): Uint8Array; - takeNumbers(): Float64Array; - takeStringIndex(): Int32Array; - takeStringIds(): Uint32Array; - takeStyleIndex(): Uint32Array; - takeStyleDict(): Uint32Array; + takePacked(): Uint8Array; takeStrings(): string[]; - takeCondMatches(): Uint32Array; }; diff --git a/packages/core/src/store/window-reader.ts b/packages/core/src/store/window-reader.ts index 1422f34d..49cbc51d 100644 --- a/packages/core/src/store/window-reader.ts +++ b/packages/core/src/store/window-reader.ts @@ -5,7 +5,7 @@ import type { SheetId } from "../types/coordinates.js"; import type { Workbook } from "../types/document.js"; import type { ResourceOwnerBytes, VisibleWindowView } from "../types/store.js"; import type { ConsumingWindowView, RecomputingCellStore } from "./wasm-contract.js"; -import { KIND_BOOL, KIND_NUMBER, KIND_STRING, NO_STRING } from "./wire-tags.js"; +import { KIND_BOOL, KIND_EMPTY, KIND_NUMBER, KIND_STRING, NO_STRING } from "./wire-tags.js"; const EMPTY_COND_MATCHES = new Uint32Array(0); // Kept independent from the worker cache cap so each thread can be tuned separately. @@ -14,6 +14,117 @@ const WINDOW_SCRATCH_MAX_REUSE = 65_536; const CONDITIONAL_MASK_BITS = Uint32Array.BYTES_PER_ELEMENT * 8; const UTF8_DECODER = new TextDecoder(); +const WINDOW_PACKED_MAGIC = 0x3157_4e53; +const WINDOW_PACKED_VERSION = 1; +const WINDOW_PACKED_HEADER_BYTES = 10 * Uint32Array.BYTES_PER_ELEMENT; + +export interface DecodedPackedWindow { + readonly nRows: number; + readonly nCols: number; + readonly kinds: Uint8Array; + readonly numbers: Float64Array; + readonly stringIds: Uint32Array; + readonly stringIndex: Int32Array; + readonly styleIds: Uint32Array; + readonly styleDict: Uint32Array; + readonly condMatches: Uint32Array; + readonly localStringCount: number; +} + +/** Validates the internal packed layout before exposing zero-copy owned-buffer views. */ +export function decodePackedWindow(packed: Uint8Array): DecodedPackedWindow { + if (packed.byteLength < WINDOW_PACKED_HEADER_BYTES) { + throw new Error("Sheetwrite: truncated packed window"); + } + const header = new DataView(packed.buffer, packed.byteOffset, WINDOW_PACKED_HEADER_BYTES); + const magic = header.getUint32(0, true); + const version = header.getUint32(4, true); + const headerBytes = header.getUint32(8, true); + const totalBytes = header.getUint32(12, true); + const nRows = header.getUint32(16, true); + const nCols = header.getUint32(20, true); + const cellCount = header.getUint32(24, true); + const styleCount = header.getUint32(28, true); + const condCount = header.getUint32(32, true); + const localStringCount = header.getUint32(36, true); + if ( + magic !== WINDOW_PACKED_MAGIC || + version !== WINDOW_PACKED_VERSION || + headerBytes !== WINDOW_PACKED_HEADER_BYTES + ) { + throw new Error("Sheetwrite: invalid packed window header"); + } + if (totalBytes > packed.byteLength) { + throw new Error("Sheetwrite: truncated packed window"); + } + if (totalBytes !== packed.byteLength) { + throw new Error("Sheetwrite: invalid packed window length"); + } + const expectedCellCount = nRows * nCols; + if (!Number.isSafeInteger(expectedCellCount) || expectedCellCount !== cellCount) { + throw new Error("Sheetwrite: invalid packed window dimensions"); + } + if (condCount !== 0 && condCount !== cellCount) { + throw new Error("Sheetwrite: invalid packed conditional-match length"); + } + + const kindsStart = WINDOW_PACKED_HEADER_BYTES; + const numbersStart = alignTo8(checkedPackedEnd(kindsStart, cellCount)); + const stringIdsStart = checkedPackedEnd(numbersStart, cellCount * Float64Array.BYTES_PER_ELEMENT); + const stringIndexStart = checkedPackedEnd( + stringIdsStart, + cellCount * Uint32Array.BYTES_PER_ELEMENT, + ); + const styleIdsStart = checkedPackedEnd( + stringIndexStart, + cellCount * Int32Array.BYTES_PER_ELEMENT, + ); + const styleDictStart = checkedPackedEnd(styleIdsStart, cellCount * Uint32Array.BYTES_PER_ELEMENT); + const condMatchesStart = checkedPackedEnd( + styleDictStart, + styleCount * Uint32Array.BYTES_PER_ELEMENT, + ); + const expectedBytes = checkedPackedEnd( + condMatchesStart, + condCount * Uint32Array.BYTES_PER_ELEMENT, + ); + if (expectedBytes !== totalBytes) { + throw new Error("Sheetwrite: invalid packed window layout"); + } + + const base = packed.byteOffset; + if ( + (base + numbersStart) % Float64Array.BYTES_PER_ELEMENT !== 0 || + (base + stringIdsStart) % Uint32Array.BYTES_PER_ELEMENT !== 0 + ) { + throw new Error("Sheetwrite: invalid packed window alignment"); + } + return { + nRows, + nCols, + kinds: new Uint8Array(packed.buffer, base + kindsStart, cellCount), + numbers: new Float64Array(packed.buffer, base + numbersStart, cellCount), + stringIds: new Uint32Array(packed.buffer, base + stringIdsStart, cellCount), + stringIndex: new Int32Array(packed.buffer, base + stringIndexStart, cellCount), + styleIds: new Uint32Array(packed.buffer, base + styleIdsStart, cellCount), + styleDict: new Uint32Array(packed.buffer, base + styleDictStart, styleCount), + condMatches: new Uint32Array(packed.buffer, base + condMatchesStart, condCount), + localStringCount, + }; +} + +function checkedPackedEnd(start: number, byteLength: number): number { + const end = start + byteLength; + if (!Number.isSafeInteger(end) || start < 0 || byteLength < 0) { + throw new Error("Sheetwrite: invalid packed window layout"); + } + return end; +} + +function alignTo8(value: number): number { + return Math.ceil(value / Float64Array.BYTES_PER_ELEMENT) * Float64Array.BYTES_PER_ELEMENT; +} + export interface PersistedCellView { readonly coordinates: Uint32Array; readonly values: readonly CellScalar[]; @@ -24,6 +135,19 @@ export interface PersistedCellView { readonly referenceTargets: Uint32Array; } +/** Exact internal diagnostics attached to each decoded visible-window read. */ +export interface VisibleWindowResourceMetrics { + readonly ffiCalls: number; + readonly ffiBoundaryCalls: number; + readonly ffiInputBytes: number; + readonly ffiOutputBytes: number; + readonly ffiLargestTransferBytes: number; + /** Fresh JS-owned packed-buffer allocation plus an optional local-string array. */ + readonly ffiOutputAllocationEvents: number; +} + +export type InstrumentedVisibleWindowView = VisibleWindowView & VisibleWindowResourceMetrics; + /** Owns packed-window decoding, caches, and reusable viewport scratch. */ export class StoreWindowReader { private readonly colsU32Cache = new WeakMap, Uint32Array>(); @@ -190,7 +314,7 @@ export class StoreWindowReader { cols: readonly number[], order: Uint32Array | undefined, applyConditionalRules: boolean, - ): VisibleWindowView { + ): InstrumentedVisibleWindowView { const handle = this.handleOf(sheet); const colsU32 = this.colsU32For(cols); let ffiCalls = 1; @@ -213,46 +337,70 @@ export class StoreWindowReader { } let view: ConsumingWindowView; + let expectedRows: number; if (order) { const dataRows = order.subarray(rows.start, Math.min(rows.end, order.length)); + expectedRows = dataRows.length; ffiInputBytes += dataRows.byteLength; ffiLargestTransferBytes = Math.max(ffiLargestTransferBytes, dataRows.byteLength); view = this.wasm.getWindowRows(handle, dataRows, colsU32) as ConsumingWindowView; } else { + expectedRows = Math.max(0, rows.end - rows.start); view = this.wasm.getWindow(handle, rows.start, rows.end, colsU32) as ConsumingWindowView; } ffiCalls += 1; - const kinds = view.takeKinds(); - const numbers = view.takeNumbers(); - const stringIds = view.takeStringIds(); - const stringIndex = view.takeStringIndex(); - const styleIds = view.takeStyleIndex(); - const styleDict = view.takeStyleDict(); - const strings = view.takeStrings(); - const condMatches = hasCondRules ? view.takeCondMatches() : EMPTY_COND_MATCHES; - view.free(); - let ffiBoundaryCalls = ffiCalls + 8 + (hasCondRules ? 1 : 0); - let ffiOutputBytes = - kinds.byteLength + - numbers.byteLength + - stringIds.byteLength + - stringIndex.byteLength + - styleIds.byteLength + - styleDict.byteLength + - condMatches.byteLength; + let packed: Uint8Array; + let decoded: DecodedPackedWindow; + let strings: string[]; + try { + packed = view.takePacked(); + decoded = decodePackedWindow(packed); + if ( + decoded.nCols !== colsU32.length || + decoded.nRows > expectedRows || + (order !== undefined && decoded.nRows !== expectedRows) || + (hasCondRules && + decoded.kinds.length > 0 && + decoded.condMatches.length !== decoded.kinds.length) + ) { + throw new Error("Sheetwrite: invalid packed window shape"); + } + strings = decoded.localStringCount === 0 ? [] : view.takeStrings(); + if (strings.length !== decoded.localStringCount) { + throw new Error("Sheetwrite: invalid packed window strings"); + } + for (let index = 0; index < decoded.kinds.length; index++) { + const kind = decoded.kinds[index]; + const local = decoded.stringIndex[index]!; + const pool = decoded.stringIds[index]!; + if ( + (kind !== KIND_EMPTY && + kind !== KIND_NUMBER && + kind !== KIND_STRING && + kind !== KIND_BOOL) || + local < -1 || + local >= strings.length || + (local >= 0 && (kind !== KIND_STRING || pool !== NO_STRING)) || + (kind === KIND_STRING && pool === NO_STRING && local < 0) + ) { + throw new Error("Sheetwrite: invalid packed window value"); + } + } + } finally { + view.free(); + } + const { kinds, numbers, stringIds, stringIndex, styleIds, styleDict } = decoded; + const condMatches = hasCondRules ? decoded.condMatches : EMPTY_COND_MATCHES; + const stringOutputAllocationEvents = decoded.localStringCount === 0 ? 0 : 1; + let ffiBoundaryCalls = ffiCalls + 2 + stringOutputAllocationEvents; let localStringBytes = 0; for (const value of strings) localStringBytes += utf8ByteLength(value); - ffiOutputBytes += localStringBytes; + let ffiOutputBytes = packed.byteLength + localStringBytes; + const ffiOutputAllocationEvents = 1 + stringOutputAllocationEvents; ffiLargestTransferBytes = Math.max( ffiLargestTransferBytes, - kinds.byteLength, - numbers.byteLength, - stringIds.byteLength, - stringIndex.byteLength, - styleIds.byteLength, - styleDict.byteLength, - condMatches.byteLength, + packed.byteLength, localStringBytes, ); @@ -330,6 +478,7 @@ export class StoreWindowReader { ffiOutputBytes, ffiLargestTransferBytes, ffiBoundaryCalls, + ffiOutputAllocationEvents, ffiCalls, }; } diff --git a/packages/core/test/conditional-format.test.ts b/packages/core/test/conditional-format.test.ts index 5265e7d7..bc63849e 100644 --- a/packages/core/test/conditional-format.test.ts +++ b/packages/core/test/conditional-format.test.ts @@ -104,6 +104,7 @@ describe("conditional formula formats", () => { ]; const store = new SheetwriteStore(workbook); const mainView = store.getVisibleWindow("s1", { start: 0, end: 1 }, [1]); + expect(Reflect.get(mainView, "ffiOutputAllocationEvents")).toBe(1); const messages: unknown[] = []; const renderer = new WorkerRenderer(); Reflect.set(renderer, "worker", { diff --git a/packages/core/test/controllers.test.ts b/packages/core/test/controllers.test.ts index 49a0c581..1a15d9ec 100644 --- a/packages/core/test/controllers.test.ts +++ b/packages/core/test/controllers.test.ts @@ -2,7 +2,7 @@ import { beforeAll, describe, expect, it } from "bun:test"; import type { AriaMirror } from "../src/aria-mirror.js"; import { DatasourceController } from "../src/datasource-controller.js"; import { DocumentController } from "../src/document-controller.js"; -import type { DomOverlay } from "../src/dom-overlay.js"; +import type { DomMergeAnchorRequest, DomOverlay } from "../src/dom-overlay.js"; import { GeometryLayoutController } from "../src/geometry-layout-controller.js"; import { DEFAULT_THEME, initSheetwrite } from "../src/grid.js"; import type { OverlayPainter } from "../src/overlay-painter.js"; @@ -237,6 +237,8 @@ describe("RenderCoordinator", () => { let scrollTop = 0; let scrollLeft = 0; let storeEpoch = 0; + let zoom = 1; + let anchorRequests: readonly DomMergeAnchorRequest[] = []; const countedStore = { getVisibleWindow: (...args: Parameters) => { windowReads += 1; @@ -250,7 +252,7 @@ describe("RenderCoordinator", () => { activeSheet: () => "s1", loadable: null, theme: () => DEFAULT_THEME, - zoom: () => 1, + zoom: () => zoom, maxElementHeight: () => 33_000_000, }, 180, @@ -272,6 +274,7 @@ describe("RenderCoordinator", () => { const viewports: Viewport[] = []; const paints: VisibleWindowView[] = []; let overlayPaints = 0; + const domAnchorViewCounts: number[] = []; let ariaUpdates = 0; const renderer: Renderer = { mount: () => {}, @@ -288,8 +291,14 @@ describe("RenderCoordinator", () => { }, } as unknown as OverlayPainter; const domOverlay = { - mergeAnchorRequests: () => [], - paint: () => {}, + mergeAnchorRequests: () => anchorRequests, + paint: ( + _view: VisibleWindowView, + _viewport: Viewport, + anchorViews: readonly VisibleWindowView[], + ) => { + domAnchorViewCounts.push(anchorViews.length); + }, paintPanes: () => {}, } as unknown as DomOverlay; const aria = { @@ -318,7 +327,7 @@ describe("RenderCoordinator", () => { activeSheet: () => "s1", theme: () => DEFAULT_THEME, overscan: () => 0, - zoom: () => 1, + zoom: () => zoom, storeEpoch: () => storeEpoch, viewportHeight: () => 180, viewportWidth: () => 420, @@ -338,11 +347,14 @@ describe("RenderCoordinator", () => { expect(overlayPaints).toBe(1); expect(ariaUpdates).toBe(1); expect(windowReads).toBe(1); + coordinator.renderNow(); + expect(paints).toHaveLength(1); + expect(windowReads).toBe(1); coordinator.invalidate(); coordinator.renderNow(); expect(paints).toHaveLength(2); - expect(viewports[1]?.contentRevision).toBe(1); + expect(viewports.at(-1)?.contentRevision).toBe(1); expect(windowReads).toBe(1); scrollTop = 1; @@ -356,14 +368,55 @@ describe("RenderCoordinator", () => { coordinator.renderNow(); expect(windowReads).toBe(2); - storeEpoch += 1; + scrollLeft = 170; coordinator.renderNow(); expect(windowReads).toBe(3); - coordinator.invalidateData(); + storeEpoch += 1; coordinator.renderNow(); expect(windowReads).toBe(4); + coordinator.invalidateData(); + coordinator.renderNow(); + expect(windowReads).toBe(5); + + const paintsBeforeZoom = paints.length; + zoom = 1.25; + coordinator.renderNow(); + expect(windowReads).toBe(5); + expect(paints).toHaveLength(paintsBeforeZoom + 1); + + anchorRequests = [{ sheet: "s1", row: 0, cols: [0] }]; + coordinator.invalidate(); + coordinator.renderNow(); + expect(windowReads).toBe(6); + expect(domAnchorViewCounts.at(-1)).toBe(1); + + coordinator.invalidate(); + coordinator.renderNow(); + expect(windowReads).toBe(6); + + anchorRequests = [{ sheet: "s1", row: 1, cols: [0] }]; + coordinator.invalidate(); + coordinator.renderNow(); + expect(windowReads).toBe(7); + + anchorRequests = [{ sheet: "s1", row: 1, cols: [1] }]; + coordinator.invalidate(); + coordinator.renderNow(); + expect(windowReads).toBe(8); + + anchorRequests = [{ sheet: "s1", row: 1, cols: [1, 2] }]; + coordinator.invalidate(); + coordinator.renderNow(); + expect(windowReads).toBe(9); + + anchorRequests = []; + coordinator.invalidate(); + coordinator.renderNow(); + expect(windowReads).toBe(9); + expect(domAnchorViewCounts.at(-1)).toBe(0); + const paintsBeforeDestroy = paints.length; coordinator.destroy(); coordinator.renderNow(); diff --git a/packages/core/test/store.test.ts b/packages/core/test/store.test.ts index b8842bb8..23e41e7c 100644 --- a/packages/core/test/store.test.ts +++ b/packages/core/test/store.test.ts @@ -106,6 +106,8 @@ describe("SheetwriteStore", () => { // row 4 begins at index 3 expect(view.values[3]).toBe("Customer 4"); expect(view.styleIds.length).toBe(9); + expect(view.ffiOutputBytes).toBeGreaterThan(0); + expect(Reflect.get(view, "ffiOutputAllocationEvents")).toBe(1); }); it("carries a non-default style id and exposes it via the dictionary", () => { @@ -1929,7 +1931,9 @@ describe("paged datasource storage", () => { }); expect(store.getCellLoadState(addr(0, 0))).toBe("unloaded"); expect(store.getCell(addr(0, 0)).resolved).toBe("#LOADING!"); - expect(store.getVisibleWindow("s1", { start: 0, end: 1 }, [0]).values).toEqual(["#LOADING!"]); + const loadingWindow = store.getVisibleWindow("s1", { start: 0, end: 1 }, [0]); + expect(loadingWindow.values).toEqual(["#LOADING!"]); + expect(Reflect.get(loadingWindow, "ffiOutputAllocationEvents")).toBe(2); expect(store.queryCapability("s1")).toEqual({ status: "incomplete", loadedCells: 0, diff --git a/packages/core/test/window-reader.test.ts b/packages/core/test/window-reader.test.ts new file mode 100644 index 00000000..7f14b8d1 --- /dev/null +++ b/packages/core/test/window-reader.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "bun:test"; +import { decodePackedWindow } from "../src/store/window-reader.js"; +import { KIND_BOOL, KIND_EMPTY, KIND_NUMBER, KIND_STRING } from "../src/store/wire-tags.js"; + +const HEADER_BYTES = 40; + +function makePackedWindow(): Uint8Array { + const kinds = new Uint8Array([KIND_EMPTY, KIND_NUMBER, KIND_STRING, KIND_BOOL]); + const numbers = new Float64Array([0, 42.5, 0, 1]); + const stringIds = new Uint32Array([0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff]); + const stringIndex = new Int32Array([-1, -1, 0, -1]); + const styleIds = new Uint32Array([0, 1, 1, 0]); + const styleDict = new Uint32Array([4, 9]); + const condMatches = new Uint32Array([0, 2, 1, 0]); + const numbersStart = (HEADER_BYTES + kinds.length + 7) & ~7; + const stringIdsStart = numbersStart + numbers.byteLength; + const stringIndexStart = stringIdsStart + stringIds.byteLength; + const styleIdsStart = stringIndexStart + stringIndex.byteLength; + const styleDictStart = styleIdsStart + styleIds.byteLength; + const condMatchesStart = styleDictStart + styleDict.byteLength; + const totalBytes = condMatchesStart + condMatches.byteLength; + const packed = new Uint8Array(totalBytes); + const header = new DataView(packed.buffer); + for (const [index, value] of [ + 0x3157_4e53, + 1, + HEADER_BYTES, + totalBytes, + 2, + 2, + kinds.length, + styleDict.length, + condMatches.length, + 1, + ].entries()) { + header.setUint32(index * 4, value, true); + } + packed.set(kinds, HEADER_BYTES); + new Float64Array(packed.buffer, numbersStart, numbers.length).set(numbers); + new Uint32Array(packed.buffer, stringIdsStart, stringIds.length).set(stringIds); + new Int32Array(packed.buffer, stringIndexStart, stringIndex.length).set(stringIndex); + new Uint32Array(packed.buffer, styleIdsStart, styleIds.length).set(styleIds); + new Uint32Array(packed.buffer, styleDictStart, styleDict.length).set(styleDict); + new Uint32Array(packed.buffer, condMatchesStart, condMatches.length).set(condMatches); + return packed; +} + +describe("packed window decoding", () => { + it("exposes aligned owned-buffer views for every value kind, styles, and conditional masks", () => { + const packed = makePackedWindow(); + const decoded = decodePackedWindow(packed); + + expect([decoded.nRows, decoded.nCols, decoded.localStringCount]).toEqual([2, 2, 1]); + expect(Array.from(decoded.kinds)).toEqual([KIND_EMPTY, KIND_NUMBER, KIND_STRING, KIND_BOOL]); + expect(Array.from(decoded.numbers)).toEqual([0, 42.5, 0, 1]); + expect(Array.from(decoded.stringIds)).toEqual([ + 0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff, + ]); + expect(Array.from(decoded.stringIndex)).toEqual([-1, -1, 0, -1]); + expect(Array.from(decoded.styleIds)).toEqual([0, 1, 1, 0]); + expect(Array.from(decoded.styleDict)).toEqual([4, 9]); + expect(Array.from(decoded.condMatches)).toEqual([0, 2, 1, 0]); + expect(decoded.kinds.buffer).toBe(packed.buffer); + expect(decoded.numbers.buffer).toBe(packed.buffer); + expect(decoded.numbers.byteOffset % Float64Array.BYTES_PER_ELEMENT).toBe(0); + }); + + it("rejects truncated, inconsistent, and unaligned layouts", () => { + expect(() => decodePackedWindow(new Uint8Array(HEADER_BYTES - 1))).toThrow( + "truncated packed window", + ); + + const truncated = makePackedWindow().slice(0, -1); + expect(() => decodePackedWindow(truncated)).toThrow("truncated packed window"); + + const badDimensions = makePackedWindow(); + new DataView(badDimensions.buffer).setUint32(24, 3, true); + expect(() => decodePackedWindow(badDimensions)).toThrow("invalid packed window dimensions"); + + const badConditionalLength = makePackedWindow(); + new DataView(badConditionalLength.buffer).setUint32(32, 1, true); + expect(() => decodePackedWindow(badConditionalLength)).toThrow( + "invalid packed conditional-match length", + ); + + const badStyleLength = makePackedWindow(); + new DataView(badStyleLength.buffer).setUint32(28, 3, true); + expect(() => decodePackedWindow(badStyleLength)).toThrow("invalid packed window layout"); + + const valid = makePackedWindow(); + const shifted = new Uint8Array(valid.byteLength + 1); + shifted.set(valid, 1); + expect(() => decodePackedWindow(shifted.subarray(1))).toThrow( + "invalid packed window alignment", + ); + }); +}); diff --git a/packages/core/test/wire-tags.test.ts b/packages/core/test/wire-tags.test.ts index 0ee80633..72aafdc7 100644 --- a/packages/core/test/wire-tags.test.ts +++ b/packages/core/test/wire-tags.test.ts @@ -1,6 +1,12 @@ import { beforeAll, describe, expect, it } from "bun:test"; import { initSheetwrite } from "../src/grid.js"; -import { KIND_BOOL, KIND_NUMBER, KIND_STRING, NO_STRING } from "../src/store/wire-tags.js"; +import { + KIND_BOOL, + KIND_EMPTY, + KIND_NUMBER, + KIND_STRING, + NO_STRING, +} from "../src/store/wire-tags.js"; import { SheetwriteStore } from "../src/store.js"; import { makeWorkbook } from "./fixtures.js"; @@ -32,8 +38,13 @@ describe("cell-kind wire tags", () => { ], }); - const view = store.getVisibleWindow("s1", { start: 0, end: 1 }, [0, 1, 2]); - expect(Array.from(view.valueKinds ?? [])).toEqual([KIND_NUMBER, KIND_STRING, KIND_BOOL]); + const view = store.getVisibleWindow("s1", { start: 0, end: 1 }, [0, 1, 2, 3]); + expect(Array.from(view.valueKinds ?? [])).toEqual([ + KIND_NUMBER, + KIND_STRING, + KIND_BOOL, + KIND_EMPTY, + ]); expect(view.stringPoolIds?.[0]).toBe(NO_STRING); } finally { store.dispose(); diff --git a/packages/wasm/Cargo.toml b/packages/wasm/Cargo.toml index dcf374d2..784e9751 100644 --- a/packages/wasm/Cargo.toml +++ b/packages/wasm/Cargo.toml @@ -10,6 +10,10 @@ publish = false crate-type = ["cdylib", "rlib"] name = "sheetwrite_wasm" +[features] +default = ["formula-engine"] +formula-engine = [] + [dependencies] wasm-bindgen = "0.2" diff --git a/packages/wasm/src/lib.rs b/packages/wasm/src/lib.rs index 8ed2352f..c06b919e 100644 --- a/packages/wasm/src/lib.rs +++ b/packages/wasm/src/lib.rs @@ -10,30 +10,46 @@ //! dictionary and the store keeps only a `u32` style id per cell. That keeps //! the large per-cell array in linear memory and the tiny dictionary in JS. //! - The render loop never reads a single cell across the boundary: it asks for -//! a whole visible window in one call (`get_window`) and receives contiguous -//! typed arrays plus the window's unique strings. +//! a whole visible window in one call (`get_window`) and receives one packed +//! fixed-width payload plus the window's unique strings. +#[cfg(not(feature = "formula-engine"))] +compile_error!( + "SHEETWRITE_STORE_ONLY_BLOCKED: formula-entry-ast,dependency-index; \ + the store-only boundary must replace FormulaEntry::ast and CellStore::dep_index" +); + +#[cfg(feature = "formula-engine")] mod calc; +#[cfg(feature = "formula-engine")] mod eval; +#[cfg(feature = "formula-engine")] mod memory; +#[cfg(feature = "formula-engine")] mod query; +#[cfg(feature = "formula-engine")] mod sheet; +#[cfg(feature = "formula-engine")] mod store; +#[cfg(feature = "formula-engine")] mod types; +#[cfg(feature = "formula-engine")] mod window; -#[cfg(test)] +#[cfg(all(test, feature = "formula-engine"))] mod tests; +#[cfg(feature = "formula-engine")] pub use store::{CellOut, CellStore}; +#[cfg(feature = "formula-engine")] pub use window::WindowView; // Test-only preludes: `tests.rs` reaches the whole crate through `use super::*`. -#[cfg(test)] +#[cfg(all(test, feature = "formula-engine"))] pub(crate) use calc::parse; -#[cfg(test)] +#[cfg(all(test, feature = "formula-engine"))] pub(crate) use query::*; -#[cfg(test)] +#[cfg(all(test, feature = "formula-engine"))] pub(crate) use sheet::*; -#[cfg(test)] +#[cfg(all(test, feature = "formula-engine"))] pub(crate) use types::*; diff --git a/packages/wasm/src/sheet.rs b/packages/wasm/src/sheet.rs index d7d2a2e0..62773b4b 100644 --- a/packages/wasm/src/sheet.rs +++ b/packages/wasm/src/sheet.rs @@ -20,7 +20,10 @@ pub(crate) enum CondPred { EqNum(f64), EqStr(String), EqEmpty, - Contains { needle: String, match_case: bool }, + Contains { + needle: String, + match_case: bool, + }, Formula { ast: Ast, anchor_row: u32, @@ -1777,12 +1780,20 @@ impl SheetData { } } -pub(crate) fn formula_error_at(sheet: &SheetData, key: CellKey) -> Option { +pub(crate) fn formula_error_with_entry( + sheet: &SheetData, + key: CellKey, + entry: Option<&FormulaEntry>, +) -> Option { sheet .spill_errors .get(&key) .copied() - .or_else(|| sheet.formulas.get(&key).and_then(|entry| entry.error)) + .or_else(|| entry.and_then(|entry| entry.error)) +} + +pub(crate) fn formula_error_at(sheet: &SheetData, key: CellKey) -> Option { + formula_error_with_entry(sheet, key, sheet.formulas.get(&key)) } #[cfg(test)] diff --git a/packages/wasm/src/tests.rs b/packages/wasm/src/tests.rs index 22440378..a217f888 100644 --- a/packages/wasm/src/tests.rs +++ b/packages/wasm/src/tests.rs @@ -21,6 +21,106 @@ fn put_number(sheet: &mut SheetData, row: usize, col: usize, value: f64) { sheet.set_num(i, value); } +struct DecodedWindow { + kinds: Vec, + numbers: Vec, + string_index: Vec, + string_ids: Vec, + style_index: Vec, + style_dict: Vec, + cond_matches: Vec, + strings: Vec, +} + +fn decode_window(mut view: WindowView) -> DecodedWindow { + let packed = view.take_packed(); + assert!(packed.len() >= 40); + let word = |index: usize| { + u32::from_le_bytes( + packed[index * 4..index * 4 + 4] + .try_into() + .expect("complete packed header word"), + ) + }; + assert_eq!(word(0), 0x3157_4e53); + assert_eq!(word(1), 1); + assert_eq!(word(2), 40); + assert_eq!(word(3) as usize, packed.len()); + let rows = word(4) as usize; + let cols = word(5) as usize; + let cells = word(6) as usize; + let styles = word(7) as usize; + let cond_matches = word(8) as usize; + let strings = word(9) as usize; + assert_eq!(rows.checked_mul(cols), Some(cells)); + assert!(cond_matches == 0 || cond_matches == cells); + + let kinds_start = 40; + let numbers_start = (kinds_start + cells + 7) & !7; + let string_ids_start = numbers_start + cells * 8; + let string_index_start = string_ids_start + cells * 4; + let style_index_start = string_index_start + cells * 4; + let style_dict_start = style_index_start + cells * 4; + let cond_matches_start = style_dict_start + styles * 4; + assert_eq!(cond_matches_start + cond_matches * 4, packed.len()); + + let decode_u32 = |start: usize, len: usize| { + (0..len) + .map(|index| { + u32::from_le_bytes( + packed[start + index * 4..start + index * 4 + 4] + .try_into() + .expect("complete packed u32"), + ) + }) + .collect::>() + }; + let decoded_strings = view.take_strings(); + assert_eq!(decoded_strings.len(), strings); + DecodedWindow { + kinds: packed[kinds_start..kinds_start + cells].to_vec(), + numbers: (0..cells) + .map(|index| { + f64::from_le_bytes( + packed[numbers_start + index * 8..numbers_start + index * 8 + 8] + .try_into() + .expect("complete packed f64"), + ) + }) + .collect(), + string_ids: decode_u32(string_ids_start, cells), + string_index: decode_u32(string_index_start, cells) + .into_iter() + .map(|value| value as i32) + .collect(), + style_index: decode_u32(style_index_start, cells), + style_dict: decode_u32(style_dict_start, styles), + cond_matches: decode_u32(cond_matches_start, cond_matches), + strings: decoded_strings, + } +} + +#[test] +fn formula_error_with_entry_uses_the_supplied_entry_and_preserves_spill_precedence() { + let key = (0, 0); + let mut sheet = SheetData::new(1, 1); + sheet + .formulas + .insert(key, FormulaEntry::error("=stored", FormulaError::Value)); + let supplied = FormulaEntry::error("=supplied", FormulaError::DivZero); + + assert_eq!( + formula_error_with_entry(&sheet, key, Some(&supplied)), + Some(FormulaError::DivZero) + ); + + sheet.spill_errors.insert(key, FormulaError::Spill); + assert_eq!( + formula_error_with_entry(&sheet, key, Some(&supplied)), + Some(FormulaError::Spill) + ); +} + #[test] fn sheet_insert_rows_moves_cells_and_shifts_formulas() { let mut sheet = SheetData::new(2, 3); @@ -464,6 +564,12 @@ fn mixed_spill_errors_materialize_at_their_array_positions() { } assert_eq!(string(&store, sheet, 1, 3).as_deref(), Some("#N/A")); + let view = decode_window(store.get_window(sheet, 1, 2, &[2, 4, 6])); + assert_eq!(view.kinds, vec![KIND_STRING, KIND_STRING, KIND_STRING]); + for index in view.string_index { + assert_eq!(view.strings[index as usize], "#N/A"); + } + let snapshot = store .capture_range(sheet, 0, 2, 3, 1) .expect("mixed-error spill history should capture"); @@ -670,11 +776,12 @@ fn value_results_feed_dependencies_and_windows() { assert_eq!(store.get_cell(sheet, 0, 3).kind(), KIND_BOOL); assert_close(number(&store, sheet, 0, 3), 0.0); - let mut view = store.get_window(sheet, 0, 1, &[0, 1, 2, 3]); - let kinds = view.take_kinds(); - let string_ids = view.take_string_ids(); - let pooled = store.pool_strings(&string_ids); - assert_eq!(kinds, vec![KIND_STRING, KIND_NUMBER, KIND_BOOL, KIND_BOOL]); + let view = decode_window(store.get_window(sheet, 0, 1, &[0, 1, 2, 3])); + let pooled = store.pool_strings(&view.string_ids); + assert_eq!( + view.kinds, + vec![KIND_STRING, KIND_NUMBER, KIND_BOOL, KIND_BOOL] + ); assert_eq!(pooled[0], "hello"); store.set_formula(sheet, 0, 0, r#"="world""#, 3); @@ -1436,26 +1543,31 @@ fn window_view_uses_error_strings_and_consuming_reads() { store.set_formula(sheet, 0, 1, "=1/0", 9); store.recompute(sheet); - let mut view = store.get_window(sheet, 0, 1, &[0, 1]); + let view = store.get_window(sheet, 0, 1, &[0, 1, u32::MAX]); assert_eq!(view.n_rows(), 1); - assert_eq!(view.n_cols(), 2); - - let kinds = view.take_kinds(); - let numbers = view.take_numbers(); - let string_index = view.take_string_index(); - let string_ids = view.take_string_ids(); - let style_index = view.take_style_index(); - let style_dict = view.take_style_dict(); - let strings = view.take_strings(); - let pooled = store.pool_strings(&string_ids); - - assert_eq!(kinds, vec![KIND_STRING, KIND_STRING]); - assert_eq!(numbers, vec![0.0, 0.0]); - assert_eq!(style_index, vec![0, 1]); - assert_eq!(style_dict, vec![7, 9]); + assert_eq!(view.n_cols(), 3); + + let view = decode_window(view); + let pooled = store.pool_strings(&view.string_ids); + + assert_eq!(view.kinds, vec![KIND_STRING, KIND_STRING, KIND_EMPTY]); + assert_eq!(view.numbers, vec![0.0, 0.0, 0.0]); + assert_eq!(view.style_index, vec![0, 1, 0]); + assert_eq!(view.style_dict, vec![7, 9]); assert_eq!(pooled[0], "hello"); - assert_eq!(strings[string_index[1] as usize], "#DIV/0!"); - assert!(view.take_kinds().is_empty()); + assert_eq!(view.strings[view.string_index[1] as usize], "#DIV/0!"); + + let mut consumed = store.get_window(sheet, 0, 1, &[0, 1]); + assert!(!consumed.take_packed().is_empty()); + assert!(consumed.take_packed().is_empty()); + + store.sheets[sheet] + .spill_errors + .insert((0, 1), FormulaError::Spill); + let spill_view = decode_window(store.get_window(sheet, 0, 1, &[1])); + assert_eq!(spill_view.kinds, vec![KIND_STRING]); + let spill_index = spill_view.string_index[0]; + assert_eq!(spill_view.strings[spill_index as usize], "#SPILL!"); } #[test] @@ -2061,12 +2173,11 @@ fn conditional_format_window_masks_cover_predicates_bounds_and_row_order() { &[0, 0, 0, 0, 0, 1], ); - let mut contiguous = store.get_window(sheet, 0, 3, &[0, 1]); - assert_eq!(contiguous.take_cond_matches(), vec![4, 18, 9, 0, 1, 34]); - assert!(contiguous.take_cond_matches().is_empty()); + let contiguous = decode_window(store.get_window(sheet, 0, 3, &[0, 1])); + assert_eq!(contiguous.cond_matches, vec![4, 18, 9, 0, 1, 34]); - let mut reordered = store.get_window_rows(sheet, &[2, 0, 1], &[1, 0]); - assert_eq!(reordered.take_cond_matches(), vec![34, 1, 18, 4, 0, 9]); + let reordered = decode_window(store.get_window_rows(sheet, &[2, 0, 1], &[1, 0])); + assert_eq!(reordered.cond_matches, vec![34, 1, 18, 4, 0, 9]); } #[test] @@ -2086,13 +2197,13 @@ fn conditional_formula_rules_shift_relative_refs_stop_and_follow_dependency_edit &[2, 0], ); - let mut initial = store.get_window(sheet, 0, 3, &[2]); - assert_eq!(initial.take_cond_matches(), vec![2, 1, 1]); + let initial = decode_window(store.get_window(sheet, 0, 3, &[2])); + assert_eq!(initial.cond_matches, vec![2, 1, 1]); store.set_number(sheet, 1, 0, 2.0, 0); store.recompute(sheet); - let mut changed_dependency = store.get_window(sheet, 0, 3, &[2]); - assert_eq!(changed_dependency.take_cond_matches(), vec![2, 2, 1]); + let changed_dependency = decode_window(store.get_window(sheet, 0, 3, &[2])); + assert_eq!(changed_dependency.cond_matches, vec![2, 2, 1]); } #[test] @@ -2517,8 +2628,7 @@ fn columns_fully_loaded_tracks_disjoint_columns_holes_bounds_and_sparse_accounti #[test] fn columns_fully_loaded_reflects_clean_eviction_and_dirty_pinning() { let mut store = CellStore::new(); - let sheet = - store.add_paged_sheet(2, 1_000_000, 4096, 110_000, DEFAULT_MAX_PAGED_DIRTY_CELLS); + let sheet = store.add_paged_sheet(2, 1_000_000, 4096, 110_000, DEFAULT_MAX_PAGED_DIRTY_CELLS); store.begin_page_load(); store.set_column_numbers(sheet, 0, 0, &[1.0], 0); @@ -2550,6 +2660,10 @@ fn paged_formulas_propagate_loading_until_dependencies_arrive() { store.set_formula(sheet, 0, 1, "=A5001+1", 0); store.recompute(sheet); assert_eq!(string(&store, sheet, 0, 1).as_deref(), Some("#LOADING!")); + let loading_view = decode_window(store.get_window(sheet, 0, 1, &[1])); + assert_eq!(loading_view.kinds, vec![KIND_STRING]); + let loading_index = loading_view.string_index[0]; + assert_eq!(loading_view.strings[loading_index as usize], "#LOADING!"); store.begin_page_load(); store.set_column_numbers(sheet, 0, 5000, &[41.0], 0); @@ -3101,8 +3215,20 @@ fn required_formula_regressions_cover_let_lookup_and_criteria_shape() { store.set_formula(sheet, 0, 2, "=SUM(LET(x,SEQUENCE(2),1),2)", 0); store.set_formula(sheet, 0, 4, "=XMATCH(2,A1:A3,0)", 0); store.set_formula(sheet, 0, 5, "=MAXIFS(A1:B1,A1:A2,\">0\")", 0); - store.set_formula(sheet, 1, 3, "=MAXIFS(CHOOSE(1,B1:B3),CHOOSE(1,A1:A3),\">1\")", 0); - store.set_formula(sheet, 1, 4, "=MAXIFS(CHOOSE(1,B1:B3),CHOOSE(1,A1:B2),\">1\")", 0); + store.set_formula( + sheet, + 1, + 3, + "=MAXIFS(CHOOSE(1,B1:B3),CHOOSE(1,A1:A3),\">1\")", + 0, + ); + store.set_formula( + sheet, + 1, + 4, + "=MAXIFS(CHOOSE(1,B1:B3),CHOOSE(1,A1:B2),\">1\")", + 0, + ); store.recompute(sheet); assert_close(number(&store, sheet, 0, 3), 5.0); assert_close(number(&store, sheet, 0, 2), 3.0); @@ -3139,7 +3265,6 @@ fn let_metadata_tracks_only_reachable_reads_and_volatility() { assert_close(number(&store, sheet, 0, 1), 1.0); } - #[test] fn required_control_lookup_reference_and_aggregate_targets_execute_end_to_end() { let mut store = CellStore::new(); @@ -3281,14 +3406,7 @@ fn every_indexed_array_producer_reinstalls_spills_after_dependency_edits() { ("=CHOOSEROWS(A1:A3,2,3)", 1, 20.0, 1, 0, 3.0), ("=LET(x,A1:A3,x)", 0, 9.0, 2, 0, 3.0), ("=CHOOSE(1,A1:A3,SEQUENCE(3))", 0, 9.0, 2, 0, 3.0), - ( - "=LET(x,CHOOSE(1,A1:A3,SEQUENCE(3)),x)", - 0, - 9.0, - 2, - 0, - 3.0, - ), + ("=LET(x,CHOOSE(1,A1:A3,SEQUENCE(3)),x)", 0, 9.0, 2, 0, 3.0), ]; for (source, edit_row, anchor, probe_row, probe_col, probe) in cases { @@ -3306,7 +3424,13 @@ fn every_indexed_array_producer_reinstalls_spills_after_dependency_edits() { assert_close(number(&store, sheet, 0, 4), 0.0); store.set_formula(sheet, 0, 4, source, 0); store.recompute(sheet); - store.set_number(sheet, edit_row, 0, if edit_row == 0 { 9.0 } else { 20.0 }, 0); + store.set_number( + sheet, + edit_row, + 0, + if edit_row == 0 { 9.0 } else { 20.0 }, + 0, + ); store.recompute(sheet); assert_close(number(&store, sheet, 0, 4), anchor); diff --git a/packages/wasm/src/window.rs b/packages/wasm/src/window.rs index 58ca099a..12b13c83 100644 --- a/packages/wasm/src/window.rs +++ b/packages/wasm/src/window.rs @@ -6,7 +6,7 @@ use wasm_bindgen::prelude::*; use crate::calc::translate_relative_refs; use crate::query::matches_needle; -use crate::sheet::{formula_error_at, CondPred, SheetData}; +use crate::sheet::{formula_error_with_entry, CondPred, SheetData}; use crate::store::CellStore; use crate::types::{ cell_key, AbsCellKey, EvalResult, FormulaError, FormulaValueKind, StringPool, Value, KIND_BOOL, @@ -16,9 +16,9 @@ use crate::types::{ #[wasm_bindgen] impl CellStore { /// One bulk read of a rectangular window for the renderer. Returns - /// contiguous typed arrays (row-major over `rows x cols`) plus the unique - /// strings referenced by the window, so the host paints without crossing - /// the boundary per cell. + /// contiguous packed cell data (row-major over `rows x cols`) plus the + /// unique strings referenced by the window, so the host paints without + /// crossing the boundary per cell. #[wasm_bindgen(js_name = getWindow)] pub fn get_window( &self, @@ -34,7 +34,9 @@ impl CellStore { let row_end = row_end.min(s.row_count); let n_rows = row_end.saturating_sub(row_start); let n_cols = cols.len(); - let cells = n_rows.saturating_mul(n_cols); + let Some(cells) = n_rows.checked_mul(n_cols) else { + return WindowView::empty(); + }; let mut kind = vec![KIND_EMPTY; cells]; let mut num = vec![0.0f64; cells]; @@ -87,9 +89,9 @@ impl CellStore { &strings, ); - WindowView { - n_rows: n_rows as u32, - n_cols: n_cols as u32, + WindowView::new( + n_rows, + n_cols, kind, num, str_local, @@ -98,7 +100,7 @@ impl CellStore { strings, style_dict, cond_matches, - } + ) } /// Bulk read of an explicit row list (sorted/filtered views) — same output @@ -110,7 +112,9 @@ impl CellStore { }; let n_rows = rows.len(); let n_cols = cols.len(); - let cells = n_rows.saturating_mul(n_cols); + let Some(cells) = n_rows.checked_mul(n_cols) else { + return WindowView::empty(); + }; let mut kind = vec![KIND_EMPTY; cells]; let mut num = vec![0.0f64; cells]; @@ -165,9 +169,9 @@ impl CellStore { &strings, ); - WindowView { - n_rows: n_rows as u32, - n_cols: n_cols as u32, + WindowView::new( + n_rows, + n_cols, kind, num, str_local, @@ -176,7 +180,7 @@ impl CellStore { strings, style_dict, cond_matches, - } + ) } } @@ -215,15 +219,14 @@ pub(crate) fn fill_window_cell( } } KIND_FORMULA => { - let error = cell_key(row, col).and_then(|key| formula_error_at(sheet, key)); + let key = cell_key(row, col); + let entry = key.and_then(|key| sheet.formulas.get(&key)); + let error = key.and_then(|key| formula_error_with_entry(sheet, key, entry)); if let Some(error) = error { kind[dst] = KIND_STRING; str_local[dst] = local_error_index(error, error_slots, strings); } else { - let value_kind = cell_key(row, col) - .and_then(|key| sheet.formulas.get(&key)) - .map(|entry| entry.value_kind); - match value_kind { + match entry.map(|entry| entry.value_kind) { Some(FormulaValueKind::Bool) => { kind[dst] = KIND_BOOL; num[dst] = sheet.num_at(src); @@ -396,36 +399,144 @@ fn cell_text<'a>( } } +const WINDOW_PACKED_MAGIC: u32 = 0x3157_4e53; +const WINDOW_PACKED_VERSION: u32 = 1; +const WINDOW_PACKED_HEADER_BYTES: usize = 10 * std::mem::size_of::(); + /// A bulk window of resolved cells, row-major over `n_rows x n_cols`. #[wasm_bindgen] pub struct WindowView { n_rows: u32, n_cols: u32, - kind: Vec, - num: Vec, - str_local: Vec, - style_local: Vec, - string_ids: Vec, + packed: Vec, strings: Vec, - style_dict: Vec, - cond_matches: Vec, } impl WindowView { - pub(crate) fn empty() -> Self { + #[allow(clippy::too_many_arguments)] + fn new( + n_rows: usize, + n_cols: usize, + kind: Vec, + num: Vec, + str_local: Vec, + style_local: Vec, + string_ids: Vec, + strings: Vec, + style_dict: Vec, + cond_matches: Vec, + ) -> Self { + let packed = pack_window( + n_rows, + n_cols, + &kind, + &num, + &str_local, + &style_local, + &string_ids, + style_dict.as_slice(), + &cond_matches, + strings.len(), + ) + .unwrap_or_default(); Self { - n_rows: 0, - n_cols: 0, - kind: Vec::new(), - num: Vec::new(), - str_local: Vec::new(), - style_local: Vec::new(), - string_ids: Vec::new(), - strings: Vec::new(), - style_dict: Vec::new(), - cond_matches: Vec::new(), + n_rows: n_rows.try_into().unwrap_or_default(), + n_cols: n_cols.try_into().unwrap_or_default(), + packed, + strings, } } + + pub(crate) fn empty() -> Self { + Self::new( + 0, + 0, + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + ) + } +} + +#[allow(clippy::too_many_arguments)] +fn pack_window( + n_rows: usize, + n_cols: usize, + kind: &[u8], + num: &[f64], + str_local: &[i32], + style_local: &[u32], + string_ids: &[u32], + style_dict: &[u32], + cond_matches: &[u32], + string_count: usize, +) -> Option> { + let cells = n_rows.checked_mul(n_cols)?; + if kind.len() != cells + || num.len() != cells + || str_local.len() != cells + || style_local.len() != cells + || string_ids.len() != cells + || (!cond_matches.is_empty() && cond_matches.len() != cells) + { + return None; + } + + let numbers_start = align_to_8(WINDOW_PACKED_HEADER_BYTES.checked_add(cells)?)?; + let string_ids_start = numbers_start.checked_add(cells.checked_mul(8)?)?; + let string_index_start = string_ids_start.checked_add(cells.checked_mul(4)?)?; + let style_index_start = string_index_start.checked_add(cells.checked_mul(4)?)?; + let style_dict_start = style_index_start.checked_add(cells.checked_mul(4)?)?; + let cond_matches_start = style_dict_start.checked_add(style_dict.len().checked_mul(4)?)?; + let total_bytes = cond_matches_start.checked_add(cond_matches.len().checked_mul(4)?)?; + + let header = [ + WINDOW_PACKED_MAGIC, + WINDOW_PACKED_VERSION, + WINDOW_PACKED_HEADER_BYTES.try_into().ok()?, + total_bytes.try_into().ok()?, + n_rows.try_into().ok()?, + n_cols.try_into().ok()?, + cells.try_into().ok()?, + style_dict.len().try_into().ok()?, + cond_matches.len().try_into().ok()?, + string_count.try_into().ok()?, + ]; + let mut packed = Vec::with_capacity(total_bytes); + for word in header { + packed.extend_from_slice(&word.to_le_bytes()); + } + packed.extend_from_slice(kind); + packed.resize(numbers_start, 0); + for value in num { + packed.extend_from_slice(&value.to_le_bytes()); + } + for value in string_ids { + packed.extend_from_slice(&value.to_le_bytes()); + } + for value in str_local { + packed.extend_from_slice(&value.to_le_bytes()); + } + for value in style_local { + packed.extend_from_slice(&value.to_le_bytes()); + } + for value in style_dict { + packed.extend_from_slice(&value.to_le_bytes()); + } + for value in cond_matches { + packed.extend_from_slice(&value.to_le_bytes()); + } + debug_assert_eq!(packed.len(), total_bytes); + Some(packed) +} + +fn align_to_8(value: usize) -> Option { + value.checked_add(7).map(|end| end & !7) } #[wasm_bindgen] @@ -440,53 +551,27 @@ impl WindowView { self.n_cols } - /// Consume and return the per-cell tag array: 0 empty, 1 number, 2 string. - #[wasm_bindgen(js_name = takeKinds)] - pub fn take_kinds(&mut self) -> Vec { - std::mem::take(&mut self.kind) + /// Consume the complete fixed-width window payload. The returned + /// `Uint8Array` is copied by wasm-bindgen into JS-owned memory. + #[wasm_bindgen(js_name = takePacked)] + pub fn take_packed(&mut self) -> Vec { + std::mem::take(&mut self.packed) } - /// Consume and return per-cell numeric payloads (valid where kind == 1). - #[wasm_bindgen(js_name = takeNumbers)] - pub fn take_numbers(&mut self) -> Vec { - std::mem::take(&mut self.num) - } - - /// Consume and return per-cell indices into `strings` (string cells only). - #[wasm_bindgen(js_name = takeStringIndex)] - pub fn take_string_index(&mut self) -> Vec { - std::mem::take(&mut self.str_local) - } - - /// Consume and return per-cell GLOBAL string-pool ids; `u32::MAX` means none. - #[wasm_bindgen(js_name = takeStringIds)] - pub fn take_string_ids(&mut self) -> Vec { - std::mem::take(&mut self.string_ids) - } - - /// Consume and return per-cell WINDOW-LOCAL style indices into the dict. - #[wasm_bindgen(js_name = takeStyleIndex)] - pub fn take_style_index(&mut self) -> Vec { - std::mem::take(&mut self.style_local) - } - - /// Consume and return the unique global style-dictionary ids referenced by - /// this window, in local-index order; the host maps each to a `CellStyle`. - #[wasm_bindgen(js_name = takeStyleDict)] - pub fn take_style_dict(&mut self) -> Vec { - std::mem::take(&mut self.style_dict) - } - - /// Consume and return unique strings referenced by this window. + /// Consume formula-error sentinel strings referenced by the packed data. #[wasm_bindgen(js_name = takeStrings)] pub fn take_strings(&mut self) -> Vec { std::mem::take(&mut self.strings) } +} + +#[cfg(test)] +mod tests { + use super::pack_window; - /// Consume and return per-cell conditional-format rule bitmasks; empty - /// when the sheet has no rules. - #[wasm_bindgen(js_name = takeCondMatches)] - pub fn take_cond_matches(&mut self) -> Vec { - std::mem::take(&mut self.cond_matches) + #[test] + fn packed_window_rejects_mismatched_cell_buffers() { + assert!(pack_window(1, 1, &[], &[0.0], &[0], &[0], &[0], &[], &[], 0).is_none()); + assert!(pack_window(1, 1, &[0], &[0.0], &[0], &[0], &[0], &[], &[0, 0], 0).is_none()); } } diff --git a/scripts/coverage-thresholds.json b/scripts/coverage-thresholds.json index d4e26d4e..95fa55bc 100644 --- a/scripts/coverage-thresholds.json +++ b/scripts/coverage-thresholds.json @@ -636,6 +636,7 @@ "packages/core/src/store.ts", "packages/core/src/store/data-engine.ts", "packages/core/src/store/mutation-policy.ts", + "packages/core/src/store/paged-dirty-preflight.ts", "packages/core/src/store/ranges.ts", "packages/core/src/store/snapshot-codec.ts", "packages/core/src/store/view-state.ts", diff --git a/scripts/first-paint-evidence.ts b/scripts/first-paint-evidence.ts new file mode 100644 index 00000000..09f76c8a --- /dev/null +++ b/scripts/first-paint-evidence.ts @@ -0,0 +1,330 @@ +import { readFile } from "node:fs/promises"; +import { type BundlerProvenance, SIZE_PROTOCOL_VERSION } from "./size-report.js"; + +export const FIRST_PAINT_BROTLI_GATE_BYTES = 7_680; +export const FIRST_PAINT_PERCENT_GATE = 10; +export const FIRST_PAINT_MIN_ROUNDS = 10; +export const FIRST_PAINT_DEFERRED_MODULE = "@sheetwrite/core/dist/clipboard-controller.js"; + +export interface FirstPaintSizes { + rawBytes: number; + gzipBytes: number; + brotliBytes: number; +} +export interface FirstPaintDelta { + rawBytes: number; + gzipBytes: number; + brotliBytes: number; + brotliPercent: number; +} + +export interface FirstPaintTiming { + samplesMs: number[]; + medianMs: number; + p95Ms: number; +} + +export interface FirstPaintVariant { + initialJavaScript: FirstPaintSizes; + timing: FirstPaintTiming; + checksum: string; +} + +export interface FirstPaintFixtureEvidence { + bundler: "next" | "vite"; + version: string; + provenance: BundlerProvenance; + eagerImports: ["@sheetwrite/core#createGrid", "@sheetwrite/core#initSheetwrite"]; + baseline: FirstPaintVariant; + candidate: FirstPaintVariant & { + deferredChunk: string; + interaction: { + module: typeof FIRST_PAINT_DEFERRED_MODULE; + loaded: true; + successOutcome: "empty"; + errorOutcome: "unsupported"; + rejected: false; + }; + }; + delta: FirstPaintDelta; +} + +export interface FirstPaintEvidence { + schemaVersion: typeof SIZE_PROTOCOL_VERSION; + kind: "first-paint-counterfactual"; + candidate: { + fixtureOnly: true; + shipping: false; + deferredModules: [typeof FIRST_PAINT_DEFERRED_MODULE]; + }; + thresholds: { + brotliBytes: typeof FIRST_PAINT_BROTLI_GATE_BYTES; + percent: typeof FIRST_PAINT_PERCENT_GATE; + timing: "median-and-p95-no-slower"; + }; + fixtures: [FirstPaintFixtureEvidence, FirstPaintFixtureEvidence]; +} + +function record(value: unknown, message: string): Record { + if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error(message); + return value as Record; +} + +function positiveInteger(value: unknown): value is number { + return Number.isSafeInteger(value) && (value as number) > 0; +} + +function finiteNonNegative(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value) && value >= 0; +} + +function sizes(value: unknown, label: string, allowZero = false): FirstPaintSizes { + const input = record(value, `${label} sizes are missing`); + for (const field of ["rawBytes", "gzipBytes", "brotliBytes"] as const) { + if ( + !(allowZero + ? Number.isSafeInteger(input[field]) && (input[field] as number) >= 0 + : positiveInteger(input[field])) + ) { + throw new Error(`${label} ${field} is missing or invalid`); + } + } + return input as unknown as FirstPaintSizes; +} + +function signedSizes(value: unknown, label: string): FirstPaintSizes { + const input = record(value, `${label} sizes are missing`); + for (const field of ["rawBytes", "gzipBytes", "brotliBytes"] as const) { + if (!Number.isSafeInteger(input[field])) { + throw new Error(`${label} ${field} is missing or invalid`); + } + } + return input as unknown as FirstPaintSizes; +} + +function percentile(sorted: readonly number[], fraction: number): number { + return sorted[Math.max(0, Math.ceil(sorted.length * fraction) - 1)]!; +} + +function timing(value: unknown, label: string): FirstPaintTiming { + const input = record(value, `${label} timing is missing`); + if ( + !Array.isArray(input.samplesMs) || + input.samplesMs.length < FIRST_PAINT_MIN_ROUNDS || + !input.samplesMs.every(finiteNonNegative) + ) { + throw new Error(`${label} timing requires ${FIRST_PAINT_MIN_ROUNDS} finite samples`); + } + const samplesMs = [...input.samplesMs].sort((left, right) => left - right) as number[]; + const middle = Math.floor(samplesMs.length / 2); + const expectedMedian = + samplesMs.length % 2 === 0 + ? (samplesMs[middle - 1]! + samplesMs[middle]!) / 2 + : samplesMs[middle]!; + const expectedP95 = percentile(samplesMs, 0.95); + if (input.medianMs !== expectedMedian || input.p95Ms !== expectedP95) { + throw new Error(`${label} timing summary does not match retained samples`); + } + return { samplesMs: input.samplesMs as number[], medianMs: expectedMedian, p95Ms: expectedP95 }; +} + +function variant(value: unknown, label: string): FirstPaintVariant { + const input = record(value, `${label} variant is missing`); + if (typeof input.checksum !== "string" || !/^[a-f0-9]{64}$/.test(input.checksum)) { + throw new Error(`${label} checksum is missing or invalid`); + } + return { + initialJavaScript: sizes(input.initialJavaScript, `${label} initial JavaScript`), + timing: timing(input.timing, `${label} first paint`), + checksum: input.checksum, + }; +} + +function provenance(value: unknown, label: string): BundlerProvenance { + const input = record(value, `${label} provenance is missing`); + const minifier = record(input.minifier, `${label} minifier is missing`); + if ( + input.buildMode !== "production" || + input.minified !== true || + typeof minifier.name !== "string" || + minifier.name.length === 0 || + typeof minifier.version !== "string" || + minifier.version.length === 0 || + !Array.isArray(input.externals) || + !input.externals.every((entry) => typeof entry === "string") || + input.target !== "browser" || + input.sourceMaps !== "hidden-external" || + input.attributionMethod !== "source-map-generated-spans-with-explicit-opaque-assets-v2" + ) { + throw new Error(`${label} provenance is incomplete or incomparable`); + } + return input as unknown as BundlerProvenance; +} + +export function validateFirstPaintEvidence(value: unknown): FirstPaintEvidence { + const input = record(value, "First-paint evidence is malformed"); + if ( + input.schemaVersion !== SIZE_PROTOCOL_VERSION || + input.kind !== "first-paint-counterfactual" + ) { + throw new Error("First-paint evidence protocol mismatch"); + } + const candidate = record(input.candidate, "First-paint candidate metadata is missing"); + if ( + candidate.fixtureOnly !== true || + candidate.shipping !== false || + !Array.isArray(candidate.deferredModules) || + candidate.deferredModules.length !== 1 || + candidate.deferredModules[0] !== FIRST_PAINT_DEFERRED_MODULE + ) { + throw new Error("First-paint candidate must be fixture-only and name its deferred module"); + } + const thresholds = record(input.thresholds, "First-paint thresholds are missing"); + if ( + thresholds.brotliBytes !== FIRST_PAINT_BROTLI_GATE_BYTES || + thresholds.percent !== FIRST_PAINT_PERCENT_GATE || + thresholds.timing !== "median-and-p95-no-slower" + ) { + throw new Error("First-paint thresholds do not match the admission contract"); + } + if (!Array.isArray(input.fixtures) || input.fixtures.length !== 2) { + throw new Error("First-paint evidence requires exactly Next.js and Vite fixtures"); + } + const seen = new Set(); + const fixtures = input.fixtures.map((value, index) => { + const fixture = record(value, `First-paint fixture ${index} is malformed`); + if ((fixture.bundler !== "next" && fixture.bundler !== "vite") || seen.has(fixture.bundler)) { + throw new Error("First-paint evidence requires one Next.js and one Vite fixture"); + } + seen.add(fixture.bundler); + if (typeof fixture.version !== "string" || fixture.version.length === 0) { + throw new Error(`${fixture.bundler} version is missing`); + } + const eagerImports = fixture.eagerImports; + if ( + !Array.isArray(eagerImports) || + eagerImports.length !== 2 || + eagerImports[0] !== "@sheetwrite/core#createGrid" || + eagerImports[1] !== "@sheetwrite/core#initSheetwrite" + ) { + throw new Error(`${fixture.bundler} eager surface is not comparable`); + } + const baseline = variant(fixture.baseline, `${fixture.bundler} baseline`); + const candidateInput = record(fixture.candidate, `${fixture.bundler} candidate is missing`); + const candidateVariant = variant(candidateInput, `${fixture.bundler} candidate`); + if (baseline.checksum !== candidateVariant.checksum) { + throw new Error(`${fixture.bundler} candidate first-paint semantics differ from baseline`); + } + if ( + typeof candidateInput.deferredChunk !== "string" || + candidateInput.deferredChunk.length === 0 + ) { + throw new Error(`${fixture.bundler} candidate deferred chunk is missing`); + } + const interaction = record( + candidateInput.interaction, + `${fixture.bundler} interaction evidence is missing`, + ); + if ( + interaction.module !== FIRST_PAINT_DEFERRED_MODULE || + interaction.loaded !== true || + interaction.successOutcome !== "empty" || + interaction.errorOutcome !== "unsupported" || + interaction.rejected !== false + ) { + throw new Error(`${fixture.bundler} interaction loading/error behavior is incomplete`); + } + const deltaInput = record(fixture.delta, `${fixture.bundler} size delta is missing`); + const deltaSizes = signedSizes(deltaInput, `${fixture.bundler} size delta`); + const expectedDelta = { + rawBytes: baseline.initialJavaScript.rawBytes - candidateVariant.initialJavaScript.rawBytes, + gzipBytes: + baseline.initialJavaScript.gzipBytes - candidateVariant.initialJavaScript.gzipBytes, + brotliBytes: + baseline.initialJavaScript.brotliBytes - candidateVariant.initialJavaScript.brotliBytes, + }; + if ( + deltaSizes.rawBytes !== expectedDelta.rawBytes || + deltaSizes.gzipBytes !== expectedDelta.gzipBytes || + deltaSizes.brotliBytes !== expectedDelta.brotliBytes || + typeof deltaInput.brotliPercent !== "number" || + deltaInput.brotliPercent !== + (expectedDelta.brotliBytes / baseline.initialJavaScript.brotliBytes) * 100 + ) { + throw new Error(`${fixture.bundler} recorded size delta is inconsistent`); + } + return { + bundler: fixture.bundler, + version: fixture.version, + provenance: provenance(fixture.provenance, fixture.bundler), + eagerImports, + baseline, + candidate: { + ...candidateVariant, + deferredChunk: candidateInput.deferredChunk, + interaction, + }, + delta: { ...deltaSizes, brotliPercent: deltaInput.brotliPercent }, + } as FirstPaintFixtureEvidence; + }); + if (!seen.has("next") || !seen.has("vite")) { + throw new Error("First-paint evidence requires one Next.js and one Vite fixture"); + } + const next = fixtures.find((fixture) => fixture.bundler === "next")!; + const vite = fixtures.find((fixture) => fixture.bundler === "vite")!; + for (const field of [ + "buildMode", + "minified", + "target", + "sourceMaps", + "attributionMethod", + ] as const) { + if (next.provenance[field] !== vite.provenance[field]) { + throw new Error(`First-paint fixture provenance differs in ${field}`); + } + } + if (JSON.stringify(next.provenance.externals) !== JSON.stringify(vite.provenance.externals)) { + throw new Error("First-paint fixture provenance uses incomparable externals"); + } + return { + schemaVersion: SIZE_PROTOCOL_VERSION, + kind: "first-paint-counterfactual", + candidate: candidate as unknown as FirstPaintEvidence["candidate"], + thresholds: thresholds as unknown as FirstPaintEvidence["thresholds"], + fixtures: fixtures as FirstPaintEvidence["fixtures"], + }; +} + +export function firstPaintGate(value: unknown): { admitted: boolean; reasons: string[] } { + const evidence = validateFirstPaintEvidence(value); + const reasons: string[] = []; + for (const fixture of evidence.fixtures) { + if (fixture.delta.brotliBytes < FIRST_PAINT_BROTLI_GATE_BYTES) { + reasons.push( + `${fixture.bundler} Brotli reduction is below ${FIRST_PAINT_BROTLI_GATE_BYTES} bytes`, + ); + } + if (fixture.delta.brotliPercent < FIRST_PAINT_PERCENT_GATE) { + reasons.push(`${fixture.bundler} Brotli reduction is below ${FIRST_PAINT_PERCENT_GATE}%`); + } + if (fixture.candidate.timing.medianMs > fixture.baseline.timing.medianMs) { + reasons.push(`${fixture.bundler} candidate median first paint is slower`); + } + if (fixture.candidate.timing.p95Ms > fixture.baseline.timing.p95Ms) { + reasons.push(`${fixture.bundler} candidate p95 first paint is slower`); + } + } + return { admitted: reasons.length === 0, reasons }; +} + +if (import.meta.main) { + if (process.argv[2] !== "check" || process.argv[3] === undefined) { + throw new Error("Usage: bun scripts/first-paint-evidence.ts check "); + } + const evidence = JSON.parse(await readFile(process.argv[3], "utf8")) as unknown; + const decision = firstPaintGate(evidence); + if (!decision.admitted) { + throw new Error(`First-paint candidate rejected:\n${decision.reasons.join("\n")}`); + } + console.log("First-paint candidate passed size and timing gates"); +} diff --git a/scripts/public-api-baseline.json b/scripts/public-api-baseline.json index 7220914f..e87e2c79 100644 --- a/scripts/public-api-baseline.json +++ b/scripts/public-api-baseline.json @@ -1,5 +1,540 @@ { - "schemaVersion": 1, + "schemaVersion": 2, "manifestFormatVersion": 2, - "sha256": "82b1c79b103c9a2ac16749c3583014ee202785a846e1b3e62769328eeb6c0ceb" + "sha256": "7598d0c8fef4270d3a0e35fd0138492103d76caca9b7779b05a502c85ac4d800", + "intentionalExports": [ + { + "package": "@sheetwrite/core", + "entryPoint": ".", + "exports": [ + "AggregateOp", + "ApplyTransactionResult", + "BoundaryDirection", + "BoundaryOperationStats", + "BoundaryTransferKind", + "CellAddress", + "CellAlign", + "CellBlock", + "CellBorder", + "CellBorders", + "CellChange", + "CellEditor", + "CellEditorContext", + "CellEditorInstance", + "CellEditorNavigation", + "CellEditorRect", + "CellFormat", + "CellHyperlink", + "CellInputSnapshot", + "CellLoadState", + "CellNote", + "CellPaintContext", + "CellRenderer", + "CellScalar", + "CellStyle", + "CellValue", + "ChangeEvent", + "Column", + "ColumnFilter", + "ColumnarData", + "CommentAdapter", + "CommentAnchor", + "CommentAuthorRef", + "CommentCoordinator", + "CommentCoordinatorEvent", + "CommentCoordinatorOptions", + "CommentListResult", + "CommentMessage", + "CommentMutation", + "CommentMutationRequest", + "CommentMutationResponse", + "CommentThread", + "CommitReason", + "ConditionalFormatPredicate", + "ConditionalFormatRule", + "ContextMenuActionName", + "ContextMenuContext", + "ContextMenuItem", + "ContextMenuItems", + "DEFAULT_DELIMITED_TEXT_RESOURCE_LIMITS", + "DEFAULT_SNAPSHOT_RESOURCE_LIMITS", + "DEFAULT_SYNC_COORDINATOR_LIMITS", + "DEFAULT_THEME", + "DEFAULT_TRANSACTION_RESOURCE_LIMITS", + "DEFAULT_WORKBOOK_TABLE_RESOURCE_LIMITS", + "DEFAULT_XLSX_RESOURCE_LIMITS", + "DataCell", + "DataSource", + "DataSourceCapabilities", + "DataSourceColumnBand", + "DataSourcePage", + "DataSourceRequest", + "DataSourceStorageOptions", + "DataValidationComparison", + "DataValidationCondition", + "DataValidationRule", + "DelimitedTextOperation", + "DelimitedTextOptions", + "DelimitedTextOptionsError", + "DelimitedTextResourceError", + "DelimitedTextResourceLimits", + "DocumentOp", + "DocumentRebaseResult", + "DocumentValidationError", + "DocumentValidationResult", + "Grid", + "GridActions", + "GridCommandName", + "GridCommandState", + "GridCommandStateChangeEvent", + "GridConfig", + "GridEvents", + "GridOptions", + "GridPresentation", + "GridTransaction", + "HighlightRange", + "HyperlinkTarget", + "IncompleteDataError", + "LegacyRowLoader", + "LegacyRowPage", + "MAX_CONDITIONAL_FORMAT_FORMULA_LENGTH", + "MAX_CONDITIONAL_FORMAT_RULES", + "MAX_HYPERLINKS_PER_SHEET", + "MAX_HYPERLINK_DISPLAY_LENGTH", + "MAX_HYPERLINK_ID_LENGTH", + "MAX_HYPERLINK_TARGET_LENGTH", + "MemoryPersistenceAdapter", + "MergeRange", + "MutationIssue", + "MutationPolicyMode", + "NamedRangeSnapshot", + "OperationSource", + "PackedCellBlock", + "PagedStoreStats", + "PendingCommit", + "PendingCommitLoadOptions", + "PendingCommitStorage", + "PersistenceAdapter", + "PersistenceCommitRequest", + "PersistenceCommitResponse", + "PersistenceError", + "PersistenceErrorCode", + "PresenceActor", + "PresenceCoordinator", + "PresenceCoordinatorEvent", + "PresenceCoordinatorOptions", + "PresenceMessage", + "PresenceOverlay", + "PresencePrivacyOptions", + "PresenceTransport", + "ProtectedRange", + "ProtectionRequest", + "ProtectionResolver", + "QueryCapability", + "REF_CYCLE", + "RUNTIME_RESOURCE_SCHEMA_VERSION", + "Range", + "RebaseConflict", + "RebaseConflictCode", + "RemoteOperationOptions", + "RemoteOperationSource", + "ReplaceResult", + "ResolvedCell", + "ResolvedHyperlinkTarget", + "ResourceOwnerBytes", + "RevisionAdapter", + "RevisionCoordinator", + "RevisionCoordinatorEvent", + "RevisionCoordinatorOptions", + "RevisionRestoreRequest", + "RevisionRestoreResponse", + "RevisionSummary", + "RowBridge", + "RowBridgeCell", + "RowBridgeClearDelta", + "RowBridgeColumn", + "RowBridgeDelta", + "RowBridgeFillDelta", + "RowBridgeHandler", + "RowBridgeHostActionDelta", + "RowBridgeId", + "RowBridgeInsertContext", + "RowBridgeMetadataDelta", + "RowBridgeOptions", + "RowBridgePasteDelta", + "RowBridgeProjection", + "RowBridgeRangeDelta", + "RowBridgeReconciliationInput", + "RowBridgeReconciliationStatus", + "RowBridgeRowStructureDelta", + "RowBridgeTransaction", + "RowBridgeUnprojectableDelta", + "RowData", + "RowGroup", + "RowMetadata", + "RuntimeMemoryObservation", + "RuntimeResourceOperation", + "RuntimeResourcePhase", + "RuntimeResourcePhaseDelta", + "RuntimeResourceSnapshot", + "SHEETWRITE_CLIPBOARD_MIME", + "SHEETWRITE_ERROR_CODES", + "SHEETWRITE_ERROR_OPERATIONS", + "STORE_MEMORY_HASH_ESTIMATE_VERSION", + "STORE_MEMORY_PROTOCOL_VERSION", + "SearchOptions", + "SearchResult", + "Selection", + "Sheet", + "SheetId", + "SheetLifecycleIssueCode", + "SheetLifecycleResult", + "SheetNameIssueCode", + "SheetNameValidationResult", + "SheetSnapshot", + "SheetVisibility", + "SheetwriteError", + "SheetwriteErrorCode", + "SheetwriteErrorContext", + "SheetwriteErrorContextValue", + "SheetwriteErrorEnvelope", + "SheetwriteErrorOperation", + "SheetwriteErrorOptions", + "SheetwriteStore", + "SheetwriteStoreOptions", + "SnapshotCell", + "SnapshotGridOptions", + "SnapshotResourceError", + "SnapshotResourceLimits", + "SnapshotStorageMode", + "SnapshotValidationError", + "SnapshotValidationOptions", + "SortKey", + "Store", + "StoreMemoryBreakdown", + "SyncActivityState", + "SyncConnectionState", + "SyncCoordinator", + "SyncCoordinatorEvent", + "SyncCoordinatorLimits", + "SyncCoordinatorOptions", + "SyncMutationRecord", + "SyncMutationStatus", + "SyncPendingCapacityError", + "SyncPendingCapacityState", + "SyncPendingQueueLimits", + "SyncProtocolError", + "SyncProtocolErrorCode", + "SyncStateSnapshot", + "SyncVersionGapRequest", + "Theme", + "ToolbarActionName", + "ToolbarIcon", + "ToolbarItem", + "Transaction", + "TransactionApplicationOptions", + "TransactionResourceLimits", + "TransactionResourceValidationResult", + "TransientResourcePeak", + "ValidationPolicy", + "VersionedCommentEvent", + "VersionedOperation", + "VisibleWindowView", + "WASM_MEMORY_OWNERS", + "WORKBOOK_SCHEMA_VERSION", + "WasmMemoryOwner", + "Workbook", + "WorkbookSnapshot", + "WorkbookTable", + "WorkbookTableColumn", + "WorkbookTableId", + "WorkbookTableNameIssueCode", + "WorkbookTableNameValidationResult", + "WorkbookTablePatch", + "WorkbookTableResourceLimits", + "WorkbookTableStyle", + "WorkbookTableUnsupportedFeature", + "XlsxResourceError", + "XlsxResourceLimits", + "XlsxTableExportBackend", + "XlsxTableImportBackend", + "XlsxWorkbookBackend", + "XlsxWorkbookOptions", + "XlsxWorkbookWarning", + "assertRuntimeResourceSnapshot", + "assertWorkbookTables", + "cellA1", + "cellScalarToText", + "colToA1", + "createGrid", + "createGridFromSnapshot", + "createRowBridge", + "createRuntimeResourceSnapshot", + "dateToSerial", + "decodeStoreMemoryStats", + "diffRuntimeResourcePhases", + "documentOpTarget", + "downloadBytes", + "emptyStoreMemoryStats", + "formatNumber", + "fromCsv", + "fromXlsxTable", + "fromXlsxWorkbook", + "hyperlinkAt", + "initSheetwrite", + "isSafeExternalHyperlink", + "isSheetwriteError", + "isSheetwriteReady", + "isValidCellHyperlink", + "isValidHyperlinkId", + "labelToCol", + "legacyFullWidthDataSource", + "observeRuntimeMemory", + "parseCellInput", + "parseCellLiteralInput", + "parseCsv", + "parseCurrencyInput", + "parseDateInput", + "rangeA1", + "rebaseDocumentOperations", + "resolveHyperlinkTarget", + "resolveThemeFromCss", + "resolveTransactionResourceLimits", + "rowBridgeTransactionId", + "sanitizeCellHyperlink", + "sanitizeHyperlinkStyle", + "serialToDate", + "setXlsxTableExportBackend", + "setXlsxTableImportBackend", + "setXlsxWorkbookBackend", + "sheetNameKey", + "shiftA1Refs", + "toCsv", + "toTsv", + "toXlsxTable", + "toXlsxWorkbook", + "validWorkbookTable", + "validateSheetName", + "validateTransactionResources", + "validateWorkbookSnapshot", + "validateWorkbookTableName", + "workbookTableNameKey" + ] + }, + { + "package": "@sheetwrite/core", + "entryPoint": "./adapter", + "exports": [ + "DEFAULT_SIMPLE_COLUMN_WIDTH", + "GRID_OPTION_POLICY", + "GridAdapterEventHandlers", + "GridController", + "GridControllerHandlers", + "GridReadyEvent", + "GridReadyReason", + "GridResetReason", + "GridSizeProps", + "OptionalGridSizeProps", + "RowBridge", + "RowBridgeCell", + "RowBridgeClearDelta", + "RowBridgeColumn", + "RowBridgeDelta", + "RowBridgeFillDelta", + "RowBridgeHandler", + "RowBridgeHostActionDelta", + "RowBridgeId", + "RowBridgeInsertContext", + "RowBridgeMetadataDelta", + "RowBridgeOptions", + "RowBridgePasteDelta", + "RowBridgeProjection", + "RowBridgeRangeDelta", + "RowBridgeReconciliationInput", + "RowBridgeReconciliationStatus", + "RowBridgeRowStructureDelta", + "RowBridgeTransaction", + "RowBridgeUnprojectableDelta", + "SHEETWRITE_ERROR_CODES", + "SHEETWRITE_ERROR_OPERATIONS", + "SheetwriteError", + "SheetwriteErrorCode", + "SheetwriteErrorContext", + "SheetwriteErrorEnvelope", + "SheetwriteErrorOperation", + "SheetwriteInitializationProps", + "SimpleColumn", + "SimpleGridInput", + "SimpleSheetwriteOptions", + "applyChangedLiveGridOptions", + "createGridController", + "createRowBridge", + "createSimpleGridInput", + "createSimpleRowBridge", + "extractGridOptions", + "getGridResetReason", + "gridSizeStyle", + "isSheetwriteError", + "rowBridgeTransactionId" + ] + }, + { + "package": "@sheetwrite/core", + "entryPoint": "./browser", + "exports": [ + "IndexedDbPendingCommitStorage", + "IndexedDbPendingCommitStorageError", + "IndexedDbPendingCommitStorageErrorCode", + "IndexedDbPendingCommitStorageOptions" + ] + }, + { + "package": "@sheetwrite/core", + "entryPoint": "./shell", + "exports": [ + "FormulaBarOptions", + "FormulaBarPiece", + "NameBoxOptions", + "ShellPiece", + "SpreadsheetShell", + "SpreadsheetShellOptions", + "ToolbarOptions", + "createFormulaBar", + "createNameBox", + "createSelectionStatus", + "createSpreadsheetShell", + "createToolbar", + "describeSelection" + ] + }, + { + "package": "@sheetwrite/core", + "entryPoint": "./testing", + "exports": [ + "CanvasTestStubOptions", + "RecordingContext2D", + "installCanvasTestStubs", + "installDatasourceClockForTest" + ] + }, + { + "package": "@sheetwrite/core", + "entryPoint": "./worker", + "exports": ["WorkerAcknowledgement", "createWorkerMessageHandler"] + }, + { + "package": "@sheetwrite/react", + "entryPoint": ".", + "exports": [ + "CellEditor", + "CellEditorContext", + "CellEditorInstance", + "CellEditorNavigation", + "CellScalar", + "Grid", + "GridCommandName", + "GridCommandState", + "GridReadyEvent", + "RowBridge", + "RowBridgeDelta", + "RowBridgeHandler", + "RowBridgeId", + "RowBridgeProjection", + "Sheetwrite", + "SheetwriteGrid", + "SheetwriteGridProps", + "SheetwriteProps", + "SimpleColumn" + ] + }, + { + "package": "@sheetwrite/svelte", + "entryPoint": ".", + "exports": [ + "CellEditor", + "CellEditorContext", + "CellEditorInstance", + "CellEditorNavigation", + "CellScalar", + "Grid", + "GridCommandName", + "GridCommandState", + "GridReadyEvent", + "RowBridge", + "RowBridgeDelta", + "RowBridgeHandler", + "RowBridgeId", + "RowBridgeProjection", + "Sheetwrite", + "SheetwriteGrid", + "SheetwriteGridProps", + "SheetwriteProps", + "SimpleColumn" + ] + }, + { + "package": "@sheetwrite/vue", + "entryPoint": ".", + "exports": [ + "CellEditor", + "CellEditorContext", + "CellEditorInstance", + "CellEditorNavigation", + "CellScalar", + "Grid", + "GridCommandName", + "GridCommandState", + "GridReadyEvent", + "RowBridge", + "RowBridgeDelta", + "RowBridgeHandler", + "RowBridgeId", + "RowBridgeProjection", + "Sheetwrite", + "SheetwriteComponentConstructor", + "SheetwriteGrid", + "SheetwriteGridEmits", + "SheetwriteGridExpose", + "SheetwriteGridProps", + "SheetwriteProps", + "SimpleColumn" + ] + }, + { + "package": "@sheetwrite/wasm", + "entryPoint": ".", + "exports": [ + "CellOut", + "CellStore", + "DistinctColumn", + "InitInput", + "InitOutput", + "RangeSnapshot", + "SourceSnapshot", + "SyncInitInput", + "WindowView", + "initSync", + "isLoaded", + "load" + ] + }, + { + "package": "@sheetwrite/xlsx", + "entryPoint": ".", + "exports": [ + "XlsxModel", + "XlsxModelCell", + "buildXlsxModel", + "registerXlsxBackends", + "sheetwriteTableExportBackend", + "sheetwriteTableImportBackend", + "sheetwriteWorkbookBackend" + ] + }, + { + "package": "@sheetwrite/xlsx", + "entryPoint": "./register", + "exports": ["registerXlsxBackends"] + } + ] } diff --git a/scripts/public-api.test.ts b/scripts/public-api.test.ts index 91ff748d..c7fc9782 100644 --- a/scripts/public-api.test.ts +++ b/scripts/public-api.test.ts @@ -5,6 +5,9 @@ import { join } from "node:path"; import { analyzePublicApi, checkManifestBaseline, + findUnusedPublicExports, + type PublicApiBaseline, + type PublicApiManifest, publicApiDigest, readPublicApiBaseline, validateManifest, @@ -81,6 +84,42 @@ async function fixture( return root; } +function baselineArtifact(manifest: PublicApiManifest): PublicApiBaseline { + return { + schemaVersion: 2, + manifestFormatVersion: manifest.formatVersion, + sha256: publicApiDigest(manifest), + intentionalExports: manifest.packages + .flatMap((pkg) => + pkg.entryPoints + .filter((entry) => entry.kind === "typescript") + .map((entry) => ({ + package: pkg.name, + entryPoint: entry.subpath, + exports: entry.exports.map((apiExport) => apiExport.name).sort(), + })), + ) + .sort((left, right) => + `${left.package}\0${left.entryPoint}`.localeCompare( + `${right.package}\0${right.entryPoint}`, + ), + ), + }; +} + +async function installBaseline( + root: string, + manifest: PublicApiManifest, +): Promise { + const baseline = baselineArtifact(manifest); + await mkdir(join(root, "scripts"), { recursive: true }); + await writeFile( + join(root, "scripts/public-api-baseline.json"), + `${JSON.stringify(baseline, null, 2)}\n`, + ); + return baseline; +} + afterEach(async () => { await Promise.all(roots.splice(0).map((root) => rm(root, { force: true, recursive: true }))); }); @@ -319,12 +358,14 @@ export class LegacyFailure extends RangeError {}`, ); }); - it("writes and reads an explicit baseline artifact", async () => { + it("writes and reads an explicit baseline artifact without changing reviewed intent", async () => { const root = await fixture(); const { manifest } = await analyzePublicApi(root); + const initial = await installBaseline(root, manifest); const written = await writePublicApiBaseline(root, manifest); expect(written.sha256).toBe(publicApiDigest(manifest)); + expect(written.intentionalExports).toEqual(initial.intentionalExports); expect(await readPublicApiBaseline(root)).toEqual(written); expect( JSON.parse(await readFile(join(root, "scripts/public-api-baseline.json"), "utf8")), @@ -336,13 +377,77 @@ export class LegacyFailure extends RangeError {}`, await mkdir(join(root, "scripts"), { recursive: true }); await writeFile( join(root, "scripts/public-api-baseline.json"), - JSON.stringify({ schemaVersion: 1, manifestFormatVersion: 2, sha256: "not-a-digest" }), + JSON.stringify({ + schemaVersion: 2, + manifestFormatVersion: 2, + sha256: "not-a-digest", + intentionalExports: [], + }), ); await expect(readPublicApiBaseline(root)).rejects.toThrow( "Invalid public API baseline artifact", ); }); + it("rejects an unreachable export outside the reviewed public contract", async () => { + const root = await fixture(); + const initial = await analyzePublicApi(root); + const baseline = await installBaseline(root, initial.manifest); + await writeFile( + join(root, "packages/core/index.d.ts"), + `${canonicalDeclarations}\n/** Accidental fixture export. */\nexport const Unreachable = true;\n`, + ); + const changed = await analyzePublicApi(root); + const refreshed = await writePublicApiBaseline(root, changed.manifest); + + expect(refreshed.intentionalExports).toEqual(baseline.intentionalExports); + + expect(await findUnusedPublicExports(root, changed.manifest, refreshed)).toContainEqual( + expect.objectContaining({ + code: "unused-export", + package: "@sheetwrite/core", + entryPoint: ".", + symbol: "Unreachable", + }), + ); + }); + + it("accepts reviewed exports and only referenced members of a workspace namespace", async () => { + const root = await fixture(); + const initial = await analyzePublicApi(root); + const baseline = baselineArtifact(initial.manifest); + expect(await findUnusedPublicExports(root, initial.manifest, baseline)).toEqual([]); + + await writeFile( + join(root, "packages/core/index.d.ts"), + `${canonicalDeclarations} +/** Consumed fixture export. */ +export const Consumed = true; +/** Unreachable fixture export. */ +export const StillUnreachable = true; +`, + ); + const consumerRoot = join(root, "packages/consumer"); + await mkdir(consumerRoot, { recursive: true }); + await writeFile( + join(consumerRoot, "package.json"), + JSON.stringify({ name: "@fixture/consumer", private: true }), + ); + await writeFile( + join(consumerRoot, "index.ts"), + 'import * as Core from "@sheetwrite/core";\nexport const observed = Core.Consumed;\n', + ); + const changed = await analyzePublicApi(root); + + const issues = await findUnusedPublicExports(root, changed.manifest, baseline); + expect(issues).not.toContainEqual( + expect.objectContaining({ code: "unused-export", symbol: "Consumed" }), + ); + expect(issues).toContainEqual( + expect.objectContaining({ code: "unused-export", symbol: "StillUnreachable" }), + ); + }); + it("detects optionality, union, generic-constraint, export-name, and JSDoc drift", async () => { const baselineRoot = await fixture(); const baseline = await analyzePublicApi(baselineRoot); diff --git a/scripts/public-api.ts b/scripts/public-api.ts index bde710e4..c9f1427b 100644 --- a/scripts/public-api.ts +++ b/scripts/public-api.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import { access, mkdir, readdir, readFile, rename, writeFile } from "node:fs/promises"; +import { access, readdir, readFile, rename, writeFile } from "node:fs/promises"; import { join, relative, resolve, sep } from "node:path"; import * as ts from "typescript-compiler"; import { PUBLIC_TYPE_DOMAINS } from "./check-import-cycles.js"; @@ -54,6 +54,7 @@ export interface ApiIssue { | "unclassified-entry" | "wrong-owner" | "unstable-error-contract" + | "unused-export" | "unresolved-entry"; message: string; package?: string; @@ -809,13 +810,36 @@ export function validateManifest(value: unknown): ApiIssue[] { } return []; } +export interface PublicApiBaselineEntry { + package: string; + entryPoint: string; + exports: string[]; +} + export interface PublicApiBaseline { - schemaVersion: 1; + schemaVersion: 2; manifestFormatVersion: PublicApiManifest["formatVersion"]; sha256: string; + /** Reviewed public intent; baseline refreshes preserve this list rather than accepting new exports. */ + intentionalExports: PublicApiBaselineEntry[]; } const PUBLIC_API_BASELINE_PATH = "scripts/public-api-baseline.json"; +const SOURCE_FILE_PATTERN = /\.(?:[cm]?[jt]sx?|svelte|vue)$/; +const IGNORED_SOURCE_DIRECTORIES: Readonly> = { + ".astro": true, + ".git": true, + ".vercel": true, + coverage: true, + dist: true, + node_modules: true, + pkg: true, + target: true, +}; + +function publicApiEntryKey(packageName: string, entryPoint: string): string { + return `${packageName}\0${entryPoint}`; +} export function publicApiDigest(manifest: PublicApiManifest): string { return createHash("sha256").update(JSON.stringify(manifest)).digest("hex"); @@ -826,19 +850,47 @@ export function parsePublicApiBaseline(value: unknown): PublicApiBaseline { typeof value !== "object" || value === null || !("schemaVersion" in value) || - value.schemaVersion !== 1 || + value.schemaVersion !== 2 || !("manifestFormatVersion" in value) || value.manifestFormatVersion !== 2 || !("sha256" in value) || typeof value.sha256 !== "string" || - !/^[0-9a-f]{64}$/.test(value.sha256) + !/^[0-9a-f]{64}$/.test(value.sha256) || + !("intentionalExports" in value) || + !Array.isArray(value.intentionalExports) ) { throw new TypeError("Invalid public API baseline artifact"); } + const entries: PublicApiBaselineEntry[] = []; + for (const entry of value.intentionalExports) { + if ( + typeof entry !== "object" || + entry === null || + !("package" in entry) || + typeof entry.package !== "string" || + !("entryPoint" in entry) || + typeof entry.entryPoint !== "string" || + !("exports" in entry) || + !Array.isArray(entry.exports) || + entry.exports.some((name: unknown) => typeof name !== "string") + ) { + throw new TypeError("Invalid public API baseline artifact"); + } + const names = entry.exports as string[]; + if (new Set(names).size !== names.length || names.join("\0") !== [...names].sort().join("\0")) { + throw new TypeError("Invalid public API baseline artifact"); + } + entries.push({ package: entry.package, entryPoint: entry.entryPoint, exports: [...names] }); + } + const keys = entries.map((entry) => publicApiEntryKey(entry.package, entry.entryPoint)); + if (new Set(keys).size !== keys.length || keys.join("\0") !== [...keys].sort().join("\0")) { + throw new TypeError("Invalid public API baseline artifact"); + } return { - schemaVersion: 1, + schemaVersion: 2, manifestFormatVersion: 2, sha256: value.sha256, + intentionalExports: entries, }; } @@ -851,14 +903,14 @@ export async function writePublicApiBaseline( repositoryRoot: string, manifest: PublicApiManifest, ): Promise { + const previous = await readPublicApiBaseline(repositoryRoot); const baseline: PublicApiBaseline = { - schemaVersion: 1, + ...previous, manifestFormatVersion: manifest.formatVersion, sha256: publicApiDigest(manifest), }; const path = join(repositoryRoot, PUBLIC_API_BASELINE_PATH); const temporaryPath = `${path}.tmp`; - await mkdir(join(path, ".."), { recursive: true }); await writeFile(temporaryPath, `${JSON.stringify(baseline, null, 2)}\n`); await rename(temporaryPath, path); return baseline; @@ -876,6 +928,246 @@ export function checkManifestBaseline(manifest: PublicApiManifest, expected: str ]; } +async function sourceFiles(directory: string): Promise { + const files: string[] = []; + const entries = await readdir(directory, { withFileTypes: true }); + entries.sort((left, right) => left.name.localeCompare(right.name)); + for (const entry of entries) { + if (entry.isDirectory() && IGNORED_SOURCE_DIRECTORIES[entry.name] === true) continue; + const path = join(directory, entry.name); + if (entry.isDirectory()) files.push(...(await sourceFiles(path))); + else if (entry.isFile() && SOURCE_FILE_PATTERN.test(entry.name)) files.push(path); + } + return files; +} + +function scriptFragments(path: string, source: string): string[] { + if (!/\.(?:svelte|vue)$/.test(path)) return [source]; + return [...source.matchAll(/]*>([\s\S]*?)<\/script>/gi)].map( + (match) => match[1] ?? "", + ); +} + +function collectImportedNames( + sourceFile: ts.SourceFile, + specifiers: ReadonlyMap, + consumed: Map>, +): void { + const record = (specifier: string, name: string): void => { + const key = specifiers.get(specifier); + if (key === undefined) return; + const names = consumed.get(key) ?? new Set(); + names.add(name); + consumed.set(key, names); + }; + const namespaceSpecifiers = new Map(); + const dynamicImportSpecifier = (expression: ts.Expression): string | undefined => { + let current = expression; + while (ts.isAwaitExpression(current) || ts.isParenthesizedExpression(current)) { + current = current.expression; + } + if (!ts.isCallExpression(current) || current.expression.kind !== ts.SyntaxKind.ImportKeyword) { + return undefined; + } + const argument = current.arguments[0]; + return current.arguments.length === 1 && argument !== undefined && ts.isStringLiteral(argument) + ? argument.text + : undefined; + }; + const visit = (node: ts.Node): void => { + if ( + ts.isImportDeclaration(node) && + ts.isStringLiteral(node.moduleSpecifier) && + node.importClause !== undefined + ) { + const specifier = node.moduleSpecifier.text; + if (node.importClause.name !== undefined) record(specifier, "default"); + const bindings = node.importClause.namedBindings; + if (bindings !== undefined) { + if (ts.isNamespaceImport(bindings)) { + namespaceSpecifiers.set(bindings.name.text, specifier); + } else { + for (const element of bindings.elements) { + record(specifier, element.propertyName?.text ?? element.name.text); + } + } + } + } else if ( + ts.isExportDeclaration(node) && + node.moduleSpecifier !== undefined && + ts.isStringLiteral(node.moduleSpecifier) && + node.exportClause !== undefined && + ts.isNamedExports(node.exportClause) + ) { + const specifier = node.moduleSpecifier.text; + for (const element of node.exportClause.elements) { + record(specifier, element.propertyName?.text ?? element.name.text); + } + } else if (ts.isVariableDeclaration(node) && node.initializer !== undefined) { + const dynamicSpecifier = dynamicImportSpecifier(node.initializer); + if (dynamicSpecifier !== undefined) { + if (ts.isIdentifier(node.name)) { + namespaceSpecifiers.set(node.name.text, dynamicSpecifier); + } else if (ts.isObjectBindingPattern(node.name)) { + for (const element of node.name.elements) { + const importedName = element.propertyName ?? element.name; + if (ts.isIdentifier(importedName) || ts.isStringLiteral(importedName)) { + record(dynamicSpecifier, importedName.text); + } + } + } + } else if ( + ts.isObjectBindingPattern(node.name) && + ts.isIdentifier(node.initializer) && + namespaceSpecifiers.has(node.initializer.text) + ) { + const specifier = namespaceSpecifiers.get(node.initializer.text) ?? ""; + for (const element of node.name.elements) { + const importedName = element.propertyName ?? element.name; + if (ts.isIdentifier(importedName) || ts.isStringLiteral(importedName)) { + record(specifier, importedName.text); + } + } + } + } else if (ts.isPropertyAccessExpression(node)) { + const dynamicSpecifier = dynamicImportSpecifier(node.expression); + if (dynamicSpecifier !== undefined) { + record(dynamicSpecifier, node.name.text); + } else if (ts.isIdentifier(node.expression)) { + record(namespaceSpecifiers.get(node.expression.text) ?? "", node.name.text); + } + } else if ( + ts.isElementAccessExpression(node) && + node.argumentExpression !== undefined && + ts.isStringLiteral(node.argumentExpression) + ) { + const dynamicSpecifier = dynamicImportSpecifier(node.expression); + if (dynamicSpecifier !== undefined) { + record(dynamicSpecifier, node.argumentExpression.text); + } else if (ts.isIdentifier(node.expression)) { + record(namespaceSpecifiers.get(node.expression.text) ?? "", node.argumentExpression.text); + } + } else if ( + ts.isQualifiedName(node) && + ts.isIdentifier(node.left) && + namespaceSpecifiers.has(node.left.text) + ) { + record(namespaceSpecifiers.get(node.left.text) ?? "", node.right.text); + } else if ( + ts.isImportTypeNode(node) && + ts.isLiteralTypeNode(node.argument) && + ts.isStringLiteral(node.argument.literal) && + node.qualifier !== undefined + ) { + record(node.argument.literal.text, node.qualifier.getText(sourceFile).split(".")[0] ?? ""); + } + ts.forEachChild(node, visit); + }; + visit(sourceFile); +} + +async function crossWorkspaceConsumers( + repositoryRoot: string, + manifest: PublicApiManifest, +): Promise>> { + const packageRoots = await packageDirectories(repositoryRoot); + const packageWorkspaces = await Promise.all( + packageRoots.map(async (root) => ({ + root, + manifest: await readJson(join(root, "package.json")), + })), + ); + const owningRoots = new Map( + packageWorkspaces + .filter( + (workspace): workspace is { root: string; manifest: PackageJson & { name: string } } => + workspace.manifest.name !== undefined, + ) + .map((workspace) => [workspace.manifest.name, workspace.root]), + ); + const explicitConsumerRoots = await Promise.all( + ["bench", "docs", "scripts", "test"].map(async (directory) => { + const path = join(repositoryRoot, directory); + return (await exists(path)) ? path : undefined; + }), + ); + const consumerRoots = [...new Set([...packageRoots, ...explicitConsumerRoots])].filter( + (root): root is string => root !== undefined, + ); + const consumed = new Map>(); + for (const consumerRoot of consumerRoots) { + const specifiers = new Map(); + for (const pkg of manifest.packages) { + if (owningRoots.get(pkg.name) === consumerRoot) continue; + for (const entry of pkg.entryPoints) { + if (entry.kind !== "typescript") continue; + specifiers.set( + entry.subpath === "." ? pkg.name : `${pkg.name}/${entry.subpath.slice(2)}`, + publicApiEntryKey(pkg.name, entry.subpath), + ); + } + } + if (specifiers.size === 0) continue; + for (const path of await sourceFiles(consumerRoot)) { + const source = await readFile(path, "utf8"); + for (const fragment of scriptFragments(path, source)) { + const sourceFile = ts.createSourceFile( + path, + fragment, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TSX, + ); + collectImportedNames(sourceFile, specifiers, consumed); + } + } + } + return consumed; +} + +export async function findUnusedPublicExports( + repositoryRoot: string, + manifest: PublicApiManifest, + baseline: PublicApiBaseline, +): Promise { + const intended = new Map( + baseline.intentionalExports.map((entry) => [ + publicApiEntryKey(entry.package, entry.entryPoint), + new Set(entry.exports), + ]), + ); + const consumed = await crossWorkspaceConsumers(repositoryRoot, manifest); + const issues: ApiIssue[] = []; + for (const pkg of manifest.packages) { + for (const entry of pkg.entryPoints) { + if (entry.kind !== "typescript") continue; + const key = publicApiEntryKey(pkg.name, entry.subpath); + const intentionalNames = intended.get(key); + const consumedNames = consumed.get(key); + for (const apiExport of entry.exports) { + if ( + intentionalNames?.has(apiExport.name) === true || + consumedNames?.has(apiExport.name) === true + ) { + continue; + } + issues.push({ + code: "unused-export", + message: `${pkg.name} ${entry.subpath} exports ${apiExport.name} without an intentional public contract or cross-workspace consumer`, + package: pkg.name, + entryPoint: entry.subpath, + symbol: apiExport.name, + }); + } + } + } + return issues.sort((left, right) => + [left.package ?? "", left.entryPoint ?? "", left.symbol ?? ""] + .join("\0") + .localeCompare([right.package ?? "", right.entryPoint ?? "", right.symbol ?? ""].join("\0")), + ); +} + export async function analyzePublicApi(repositoryRoot: string): Promise<{ manifest: PublicApiManifest; issues: ApiIssue[]; @@ -976,8 +1268,10 @@ function formatIssues(issues: readonly ApiIssue[]): string { if (import.meta.main) { const mode = process.argv[2]; const repositoryRoot = resolve(process.argv[3] ?? join(import.meta.dir, "..")); - if (mode !== "report" && mode !== "check" && mode !== "baseline") { - throw new Error("Usage: bun scripts/public-api.ts [repository-root]"); + if (mode !== "report" && mode !== "check" && mode !== "baseline" && mode !== "unused") { + throw new Error( + "Usage: bun scripts/public-api.ts [repository-root]", + ); } const result = await analyzePublicApi(repositoryRoot); if (mode === "report") { @@ -993,13 +1287,30 @@ if (import.meta.main) { } process.stdout.write(`${JSON.stringify(result.manifest, null, 2)}\n`); } else if (mode === "baseline") { - if (result.issues.length > 0) throw new Error(formatIssues(result.issues)); + const existingBaseline = await readPublicApiBaseline(repositoryRoot); + const unusedIssues = await findUnusedPublicExports( + repositoryRoot, + result.manifest, + existingBaseline, + ); + const issues = [...result.issues, ...unusedIssues]; + if (issues.length > 0) throw new Error(formatIssues(issues)); const baseline = await writePublicApiBaseline(repositoryRoot, result.manifest); console.log(`Public API baseline updated: ${baseline.sha256}`); } else { const baseline = await readPublicApiBaseline(repositoryRoot); - const issues = [...result.issues, ...checkManifestBaseline(result.manifest, baseline.sha256)]; + const unusedIssues = await findUnusedPublicExports(repositoryRoot, result.manifest, baseline); + const issues = + mode === "unused" + ? [...result.issues, ...unusedIssues] + : [ + ...result.issues, + ...unusedIssues, + ...checkManifestBaseline(result.manifest, baseline.sha256), + ]; if (issues.length > 0) throw new Error(formatIssues(issues)); - console.log("Public API policy check passed"); + console.log( + mode === "unused" ? "Unused public export check passed" : "Public API policy check passed", + ); } } diff --git a/scripts/release-artifacts.test.ts b/scripts/release-artifacts.test.ts index 95a65ca2..fed3a7c4 100644 --- a/scripts/release-artifacts.test.ts +++ b/scripts/release-artifacts.test.ts @@ -97,7 +97,6 @@ const REQUIRED_FILES: Readonly> = { "LICENSE", "dist/index.d.ts", "dist/index.js", - "dist/index.js.map", "dist/worker.d.ts", "dist/worker.js", "styles.css", @@ -187,6 +186,22 @@ describe("canonical release artifacts", () => { expect(() => validateReleaseManifest(manifest())).not.toThrow(); }); + it("rejects source maps in the published core package", () => { + const base = manifest(); + const packages = base.packages.map((artifact) => + artifact.name === "@sheetwrite/core" + ? { + ...artifact, + fileCount: 2, + files: ["dist/index.js.map", "package.json"], + } + : artifact, + ); + expect(() => validateReleaseManifest({ ...base, packages })).toThrow( + "@sheetwrite/core published files must exclude source maps", + ); + }); + it("rewrites workspace ranges and enforces release cleanliness", () => { const versions = new Map([ ["@sheetwrite/core", "0.1.0"], diff --git a/scripts/release-artifacts.ts b/scripts/release-artifacts.ts index cfec3728..d79b9fa1 100644 --- a/scripts/release-artifacts.ts +++ b/scripts/release-artifacts.ts @@ -1,7 +1,7 @@ import { createHash } from "node:crypto"; import { cp, mkdir, mkdtemp, readdir, readFile, rm, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { basename, join, resolve } from "node:path"; +import { basename, dirname, join, resolve } from "node:path"; import { WASM_PACK_VERSION } from "./install-wasm-pack.js"; import { BUN_VERSION, @@ -18,6 +18,14 @@ export const RELEASE_ARTIFACT_SCHEMA_VERSION = 2; export const RELEASE_ARTIFACT_MANIFEST = "release-artifacts.json"; export const RELEASE_BUILD_COMMAND = "bun run build:packages"; +export function assertPublishedFilePolicy(packageName: string, files: readonly string[]): void { + if (packageName !== "@sheetwrite/core") return; + const sourceMap = files.find((path) => path.toLowerCase().endsWith(".map")); + if (sourceMap !== undefined) { + throw new Error(`${packageName} published files must exclude source maps; found ${sourceMap}`); + } +} + interface PackageManifest { readonly name: string; readonly version: string; @@ -322,6 +330,7 @@ export function validateReleaseManifest(manifest: ReleaseArtifactManifest): void if ([...artifact.files].sort().some((path, fileIndex) => path !== artifact.files[fileIndex])) { throw new Error(`${artifact.name} packed file list must be sorted`); } + assertPublishedFilePolicy(artifact.name, artifact.files); assertString(artifact.shasum, `${artifact.name} shasum`); if (!/^[0-9a-f]{40}$/.test(artifact.shasum)) { throw new Error(`${artifact.name} shasum must be SHA-1 hex`); @@ -373,7 +382,6 @@ const REQUIRED_PACKAGE_FILES: Readonly> = { "LICENSE", "dist/index.d.ts", "dist/index.js", - "dist/index.js.map", "dist/worker.d.ts", "dist/worker.js", "styles.css", @@ -415,6 +423,15 @@ function packageTargets(manifest: PackageManifest): string[] { return [...targets]; } +function manifestEntryExists(entry: string, files: readonly string[]): boolean { + const path = entry.replace(/^\.\//, "").replace(/\/$/, ""); + if (/[*?[\]{}]/u.test(path)) { + const glob = new Bun.Glob(path); + return files.some((file) => glob.match(file)); + } + return files.includes(path) || files.some((file) => file.startsWith(`${path}/`)); +} + function validatePackedManifest( artifact: ReleasePackageArtifact, manifest: PackageManifest, @@ -452,8 +469,7 @@ function validatePackedManifest( } } for (const declared of manifest.files ?? []) { - const path = declared.replace(/^\.\//, "").replace(/\/$/, ""); - if (!files.has(path) && !artifact.files.some((file) => file.startsWith(`${path}/`))) { + if (!manifestEntryExists(declared, artifact.files)) { throw new Error( `${artifact.name} declared file target does not exist with exact case: ${declared}`, ); @@ -576,6 +592,31 @@ interface StagedPackage { readonly internalDependencies: Readonly>; } +async function copyManifestEntry( + sourceRoot: string, + packageRoot: string, + entry: string, +): Promise { + if (!/[*?[\]{}]/u.test(entry)) { + await cp(join(sourceRoot, entry), join(packageRoot, entry), { recursive: true }); + return; + } + const matches = Array.from( + new Bun.Glob(entry).scanSync({ + cwd: sourceRoot, + dot: true, + onlyFiles: true, + followSymlinks: false, + }), + ); + if (matches.length === 0) throw new Error(`Package file pattern matched nothing: ${entry}`); + for (const path of matches) { + const target = join(packageRoot, path); + await mkdir(dirname(target), { recursive: true }); + await cp(join(sourceRoot, path), target); + } +} + async function stagePackage( source: { readonly directory: string; readonly manifest: PackageManifest }, stageRoot: string, @@ -585,7 +626,7 @@ async function stagePackage( await mkdir(target, { recursive: true }); const paths = new Set([...(source.manifest.files ?? []), "LICENSE", "README.md"]); for (const path of paths) { - await cp(join(source.directory, path), join(target, path), { recursive: true }); + await copyManifestEntry(source.directory, target, path); } const scripts = { ...source.manifest.scripts }; delete scripts.prepublishOnly; diff --git a/scripts/release-verify.test.ts b/scripts/release-verify.test.ts index 1da083dd..17f9824e 100644 --- a/scripts/release-verify.test.ts +++ b/scripts/release-verify.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, it } from "bun:test"; +import { afterEach, describe, expect, it } from "bun:test"; +import { rmSync } from "node:fs"; import { resolve } from "node:path"; import { type ReleaseCommandContext, @@ -8,6 +9,10 @@ import { const repositoryRoot = resolve(import.meta.dir, ".."); +afterEach(() => { + rmSync(resolve(import.meta.dir, "size-report-failure.json"), { force: true }); +}); + function guardedRun(command: readonly [string, ...string[]]): { readonly exitCode: number; readonly output: string; diff --git a/scripts/size-report-failure.json b/scripts/size-report-failure.json deleted file mode 100644 index e4552f60..00000000 --- a/scripts/size-report-failure.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "schemaVersion": 1, - "tool": { - "name": "sheetwrite-delivery-size", - "version": "1.0.0" - }, - "error": "Artifact-only size reporting requires --artifacts", - "reproduction": "bun run size:report" -} diff --git a/scripts/size-report.json b/scripts/size-report.json index 63c37678..265c5c1c 100644 --- a/scripts/size-report.json +++ b/scripts/size-report.json @@ -1,14 +1,14 @@ { - "schemaVersion": 1, - "protocolVersion": 1, + "schemaVersion": 2, + "protocolVersion": 2, "tool": { "name": "sheetwrite-delivery-size", - "version": "1.0.0" + "version": "2.0.0" }, "meta": { - "commit": "dfc77cbbc29eb32e2fb0d8ffd01c438199223afe", - "dirty": true, - "timestamp": "2026-07-22T20:13:22.607Z" + "commit": "1373200b8ab98d99525c2814135a20ae6dadc4e0", + "dirty": false, + "timestamp": "2026-08-09T14:35:00.022Z" }, "toolchain": { "bun": "1.3.14", @@ -19,6 +19,162 @@ "webpack": "5.102.1" }, "metrics": { + "attribution.next.collaboration.brotliBytes": { + "actual": 0, + "unit": "bytes", + "category": "browser-module-attribution", + "owner": "next:@sheetwrite/core/collaboration" + }, + "attribution.next.collaboration.rawBytes": { + "actual": 0, + "unit": "bytes", + "category": "browser-module-attribution", + "owner": "next:@sheetwrite/core/collaboration" + }, + "attribution.next.coreFirstPaint.rootBarrelRawBytes": { + "actual": 0, + "unit": "bytes", + "category": "browser-module-attribution", + "owner": "next:@sheetwrite/core" + }, + "attribution.next.coreFirstPaint.sheetwriteRawBytes": { + "actual": 433004, + "unit": "bytes", + "category": "browser-module-attribution", + "owner": "next:@sheetwrite/core" + }, + "attribution.next.persistence.brotliBytes": { + "actual": 0, + "unit": "bytes", + "category": "browser-module-attribution", + "owner": "next:@sheetwrite/core/persistence" + }, + "attribution.next.persistence.rawBytes": { + "actual": 0, + "unit": "bytes", + "category": "browser-module-attribution", + "owner": "next:@sheetwrite/core/persistence" + }, + "attribution.next.rebase.brotliBytes": { + "actual": 0, + "unit": "bytes", + "category": "browser-module-attribution", + "owner": "next:@sheetwrite/core/rebase" + }, + "attribution.next.rebase.rawBytes": { + "actual": 0, + "unit": "bytes", + "category": "browser-module-attribution", + "owner": "next:@sheetwrite/core/rebase" + }, + "attribution.next.sync.brotliBytes": { + "actual": 0, + "unit": "bytes", + "category": "browser-module-attribution", + "owner": "next:@sheetwrite/core/sync" + }, + "attribution.next.sync.rawBytes": { + "actual": 0, + "unit": "bytes", + "category": "browser-module-attribution", + "owner": "next:@sheetwrite/core/sync" + }, + "attribution.vite.collaboration.brotliBytes": { + "actual": 0, + "unit": "bytes", + "category": "browser-module-attribution", + "owner": "vite:@sheetwrite/core/collaboration" + }, + "attribution.vite.collaboration.rawBytes": { + "actual": 0, + "unit": "bytes", + "category": "browser-module-attribution", + "owner": "vite:@sheetwrite/core/collaboration" + }, + "attribution.vite.coreFirstPaint.rootBarrelRawBytes": { + "actual": 0, + "unit": "bytes", + "category": "browser-module-attribution", + "owner": "vite:@sheetwrite/core" + }, + "attribution.vite.coreFirstPaint.sheetwriteRawBytes": { + "actual": 440639, + "unit": "bytes", + "category": "browser-module-attribution", + "owner": "vite:@sheetwrite/core" + }, + "attribution.vite.persistence.brotliBytes": { + "actual": 0, + "unit": "bytes", + "category": "browser-module-attribution", + "owner": "vite:@sheetwrite/core/persistence" + }, + "attribution.vite.persistence.rawBytes": { + "actual": 0, + "unit": "bytes", + "category": "browser-module-attribution", + "owner": "vite:@sheetwrite/core/persistence" + }, + "attribution.vite.rebase.brotliBytes": { + "actual": 0, + "unit": "bytes", + "category": "browser-module-attribution", + "owner": "vite:@sheetwrite/core/rebase" + }, + "attribution.vite.rebase.rawBytes": { + "actual": 0, + "unit": "bytes", + "category": "browser-module-attribution", + "owner": "vite:@sheetwrite/core/rebase" + }, + "attribution.vite.sync.brotliBytes": { + "actual": 0, + "unit": "bytes", + "category": "browser-module-attribution", + "owner": "vite:@sheetwrite/core/sync" + }, + "attribution.vite.sync.rawBytes": { + "actual": 0, + "unit": "bytes", + "category": "browser-module-attribution", + "owner": "vite:@sheetwrite/core/sync" + }, + "bundler.next.coreAttributionInitial.css.brotliBytes": { + "actual": 2177, + "unit": "bytes", + "category": "browser-css", + "owner": "next:core-attribution-initial" + }, + "bundler.next.coreAttributionInitial.css.gzipBytes": { + "actual": 2508, + "unit": "bytes", + "category": "browser-css", + "owner": "next:core-attribution-initial" + }, + "bundler.next.coreAttributionInitial.css.rawBytes": { + "actual": 12299, + "unit": "bytes", + "category": "browser-css", + "owner": "next:core-attribution-initial" + }, + "bundler.next.coreAttributionInitial.javascript.brotliBytes": { + "actual": 224390, + "unit": "bytes", + "category": "browser-javascript", + "owner": "next:core-attribution-initial" + }, + "bundler.next.coreAttributionInitial.javascript.gzipBytes": { + "actual": 268252, + "unit": "bytes", + "category": "browser-javascript", + "owner": "next:core-attribution-initial" + }, + "bundler.next.coreAttributionInitial.javascript.rawBytes": { + "actual": 917158, + "unit": "bytes", + "category": "browser-javascript", + "owner": "next:core-attribution-initial" + }, "bundler.next.coreInitial.css.brotliBytes": { "actual": 2177, "unit": "bytes", @@ -38,55 +194,55 @@ "owner": "next:core-initial" }, "bundler.next.coreInitial.javascript.brotliBytes": { - "actual": 227446, + "actual": 229373, "unit": "bytes", "category": "browser-javascript", "owner": "next:core-initial" }, "bundler.next.coreInitial.javascript.gzipBytes": { - "actual": 272020, + "actual": 273747, "unit": "bytes", "category": "browser-javascript", "owner": "next:core-initial" }, "bundler.next.coreInitial.javascript.rawBytes": { - "actual": 928973, + "actual": 934706, "unit": "bytes", "category": "browser-javascript", "owner": "next:core-initial" }, "bundler.next.coreInitial.wasm.brotliBytes": { - "actual": 208727, + "actual": 212236, "unit": "bytes", "category": "browser-wasm", "owner": "next:core-initial" }, "bundler.next.coreInitial.wasm.gzipBytes": { - "actual": 268969, + "actual": 274036, "unit": "bytes", "category": "browser-wasm", "owner": "next:core-initial" }, "bundler.next.coreInitial.wasm.rawBytes": { - "actual": 683490, + "actual": 695109, "unit": "bytes", "category": "browser-wasm", "owner": "next:core-initial" }, "bundler.next.nextAsync.javascript.brotliBytes": { - "actual": 83659, + "actual": 83789, "unit": "bytes", "category": "browser-javascript", "owner": "next:next-async" }, "bundler.next.nextAsync.javascript.gzipBytes": { - "actual": 97989, + "actual": 98155, "unit": "bytes", "category": "browser-javascript", "owner": "next:next-async" }, "bundler.next.nextAsync.javascript.rawBytes": { - "actual": 317664, + "actual": 318007, "unit": "bytes", "category": "browser-javascript", "owner": "next:next-async" @@ -110,37 +266,37 @@ "owner": "vite:core-initial" }, "bundler.vite.coreInitial.javascript.brotliBytes": { - "actual": 109149, + "actual": 110480, "unit": "bytes", "category": "browser-javascript", "owner": "vite:core-initial" }, "bundler.vite.coreInitial.javascript.gzipBytes": { - "actual": 129129, + "actual": 130722, "unit": "bytes", "category": "browser-javascript", "owner": "vite:core-initial" }, "bundler.vite.coreInitial.javascript.rawBytes": { - "actual": 458055, + "actual": 463910, "unit": "bytes", "category": "browser-javascript", "owner": "vite:core-initial" }, "bundler.vite.coreInitial.wasm.brotliBytes": { - "actual": 208727, + "actual": 212236, "unit": "bytes", "category": "browser-wasm", "owner": "vite:core-initial" }, "bundler.vite.coreInitial.wasm.gzipBytes": { - "actual": 268969, + "actual": 274036, "unit": "bytes", "category": "browser-wasm", "owner": "vite:core-initial" }, "bundler.vite.coreInitial.wasm.rawBytes": { - "actual": 683490, + "actual": 695109, "unit": "bytes", "category": "browser-wasm", "owner": "vite:core-initial" @@ -164,37 +320,37 @@ "owner": "vite:react-initial" }, "bundler.vite.reactInitial.javascript.brotliBytes": { - "actual": 165099, + "actual": 166506, "unit": "bytes", "category": "browser-javascript", "owner": "vite:react-initial" }, "bundler.vite.reactInitial.javascript.gzipBytes": { - "actual": 193830, + "actual": 195424, "unit": "bytes", "category": "browser-javascript", "owner": "vite:react-initial" }, "bundler.vite.reactInitial.javascript.rawBytes": { - "actual": 662027, + "actual": 667882, "unit": "bytes", "category": "browser-javascript", "owner": "vite:react-initial" }, "bundler.vite.reactInitial.wasm.brotliBytes": { - "actual": 208727, + "actual": 212236, "unit": "bytes", "category": "browser-wasm", "owner": "vite:react-initial" }, "bundler.vite.reactInitial.wasm.gzipBytes": { - "actual": 268969, + "actual": 274036, "unit": "bytes", "category": "browser-wasm", "owner": "vite:react-initial" }, "bundler.vite.reactInitial.wasm.rawBytes": { - "actual": 683490, + "actual": 695109, "unit": "bytes", "category": "browser-wasm", "owner": "vite:react-initial" @@ -218,37 +374,37 @@ "owner": "vite:svelte-initial" }, "bundler.vite.svelteInitial.javascript.brotliBytes": { - "actual": 127928, + "actual": 129261, "unit": "bytes", "category": "browser-javascript", "owner": "vite:svelte-initial" }, "bundler.vite.svelteInitial.javascript.gzipBytes": { - "actual": 149801, + "actual": 151395, "unit": "bytes", "category": "browser-javascript", "owner": "vite:svelte-initial" }, "bundler.vite.svelteInitial.javascript.rawBytes": { - "actual": 513635, + "actual": 519490, "unit": "bytes", "category": "browser-javascript", "owner": "vite:svelte-initial" }, "bundler.vite.svelteInitial.wasm.brotliBytes": { - "actual": 208727, + "actual": 212236, "unit": "bytes", "category": "browser-wasm", "owner": "vite:svelte-initial" }, "bundler.vite.svelteInitial.wasm.gzipBytes": { - "actual": 268969, + "actual": 274036, "unit": "bytes", "category": "browser-wasm", "owner": "vite:svelte-initial" }, "bundler.vite.svelteInitial.wasm.rawBytes": { - "actual": 683490, + "actual": 695109, "unit": "bytes", "category": "browser-wasm", "owner": "vite:svelte-initial" @@ -272,67 +428,67 @@ "owner": "vite:vue-initial" }, "bundler.vite.vueInitial.javascript.brotliBytes": { - "actual": 136703, + "actual": 138080, "unit": "bytes", "category": "browser-javascript", "owner": "vite:vue-initial" }, "bundler.vite.vueInitial.javascript.gzipBytes": { - "actual": 159552, + "actual": 161146, "unit": "bytes", "category": "browser-javascript", "owner": "vite:vue-initial" }, "bundler.vite.vueInitial.javascript.rawBytes": { - "actual": 537956, + "actual": 543811, "unit": "bytes", "category": "browser-javascript", "owner": "vite:vue-initial" }, "bundler.vite.vueInitial.wasm.brotliBytes": { - "actual": 208727, + "actual": 212236, "unit": "bytes", "category": "browser-wasm", "owner": "vite:vue-initial" }, "bundler.vite.vueInitial.wasm.gzipBytes": { - "actual": 268969, + "actual": 274036, "unit": "bytes", "category": "browser-wasm", "owner": "vite:vue-initial" }, "bundler.vite.vueInitial.wasm.rawBytes": { - "actual": 683490, + "actual": 695109, "unit": "bytes", "category": "browser-wasm", "owner": "vite:vue-initial" }, "bundler.vite.workerAsync.javascript.brotliBytes": { - "actual": 8606, + "actual": 8825, "unit": "bytes", "category": "browser-javascript", "owner": "vite:worker-async" }, "bundler.vite.workerAsync.javascript.gzipBytes": { - "actual": 9469, + "actual": 9763, "unit": "bytes", "category": "browser-javascript", "owner": "vite:worker-async" }, "bundler.vite.workerAsync.javascript.rawBytes": { - "actual": 26822, + "actual": 27656, "unit": "bytes", "category": "browser-javascript", "owner": "vite:worker-async" }, "bundler.vite.workerLoaderInitial.javascript.brotliBytes": { - "actual": 441, + "actual": 465, "unit": "bytes", "category": "browser-javascript", "owner": "vite:worker-loader-initial" }, "bundler.vite.workerLoaderInitial.javascript.gzipBytes": { - "actual": 556, + "actual": 553, "unit": "bytes", "category": "browser-javascript", "owner": "vite:worker-loader-initial" @@ -344,7 +500,7 @@ "owner": "vite:worker-loader-initial" }, "bundler.vite.xlsxAsync.javascript.brotliBytes": { - "actual": 32620, + "actual": 32602, "unit": "bytes", "category": "browser-javascript", "owner": "vite:xlsx-async" @@ -362,13 +518,13 @@ "owner": "vite:xlsx-async" }, "bundler.vite.xlsxLoaderInitial.javascript.brotliBytes": { - "actual": 1066, + "actual": 1072, "unit": "bytes", "category": "browser-javascript", "owner": "vite:xlsx-loader-initial" }, "bundler.vite.xlsxLoaderInitial.javascript.gzipBytes": { - "actual": 1280, + "actual": 1278, "unit": "bytes", "category": "browser-javascript", "owner": "vite:xlsx-loader-initial" @@ -380,61 +536,61 @@ "owner": "vite:xlsx-loader-initial" }, "bundler.webpack.coreInitial.javascript.brotliBytes": { - "actual": 6765, + "actual": 6640, "unit": "bytes", "category": "browser-javascript", "owner": "webpack:core-initial" }, "bundler.webpack.coreInitial.javascript.gzipBytes": { - "actual": 7734, + "actual": 7593, "unit": "bytes", "category": "browser-javascript", "owner": "webpack:core-initial" }, "bundler.webpack.coreInitial.javascript.rawBytes": { - "actual": 29855, + "actual": 28818, "unit": "bytes", "category": "browser-javascript", "owner": "webpack:core-initial" }, "bundler.webpack.coreInitial.wasm.brotliBytes": { - "actual": 208727, + "actual": 212236, "unit": "bytes", "category": "browser-wasm", "owner": "webpack:core-initial" }, "bundler.webpack.coreInitial.wasm.gzipBytes": { - "actual": 268969, + "actual": 274036, "unit": "bytes", "category": "browser-wasm", "owner": "webpack:core-initial" }, "bundler.webpack.coreInitial.wasm.rawBytes": { - "actual": 683490, + "actual": 695109, "unit": "bytes", "category": "browser-wasm", "owner": "webpack:core-initial" }, "bundler.webpack.workerAsync.javascript.brotliBytes": { - "actual": 6781, + "actual": 6909, "unit": "bytes", "category": "browser-javascript", "owner": "webpack:worker-async" }, "bundler.webpack.workerAsync.javascript.gzipBytes": { - "actual": 7466, + "actual": 7608, "unit": "bytes", "category": "browser-javascript", "owner": "webpack:worker-async" }, "bundler.webpack.workerAsync.javascript.rawBytes": { - "actual": 19245, + "actual": 19513, "unit": "bytes", "category": "browser-javascript", "owner": "webpack:worker-async" }, "closure.core-xlsx.logicalBytes": { - "actual": 4887429, + "actual": 3542354, "unit": "bytes", "category": "install-closure", "owner": "core-xlsx" @@ -446,7 +602,7 @@ "owner": "core-xlsx" }, "closure.core.logicalBytes": { - "actual": 3650816, + "actual": 2305741, "unit": "bytes", "category": "install-closure", "owner": "core" @@ -464,7 +620,7 @@ "owner": "core" }, "closure.react.logicalBytes": { - "actual": 3855099, + "actual": 2510024, "unit": "bytes", "category": "install-closure", "owner": "react" @@ -482,7 +638,7 @@ "owner": "react" }, "closure.svelte.logicalBytes": { - "actual": 8704597, + "actual": 7359522, "unit": "bytes", "category": "install-closure", "owner": "svelte" @@ -500,7 +656,7 @@ "owner": "svelte" }, "closure.vue.logicalBytes": { - "actual": 20545448, + "actual": 19200373, "unit": "bytes", "category": "install-closure", "owner": "vue" @@ -556,383 +712,383 @@ "package.@sheetwrite/core.cssBytes": { "actual": 19374, "unit": "bytes", - "category": "package-css", + "category": "package-unpacked-css", "owner": "@sheetwrite/core" }, "package.@sheetwrite/core.declarationsBytes": { - "actual": 312860, + "actual": 316651, "unit": "bytes", - "category": "package-declarations", + "category": "package-unpacked-declarations", "owner": "@sheetwrite/core" }, "package.@sheetwrite/core.fileCount": { - "actual": 335, + "actual": 174, "unit": "count", - "category": "package", + "category": "package-files", "owner": "@sheetwrite/core" }, "package.@sheetwrite/core.mapsBytes": { - "actual": 1371090, + "actual": 0, "unit": "bytes", - "category": "package-maps", + "category": "package-unpacked-maps", "owner": "@sheetwrite/core" }, "package.@sheetwrite/core.metadataBytes": { - "actual": 5096, + "actual": 5126, "unit": "bytes", - "category": "package-metadata", + "category": "package-unpacked-metadata", "owner": "@sheetwrite/core" }, "package.@sheetwrite/core.runtimeJsBytes": { - "actual": 1136966, + "actual": 1152396, "unit": "bytes", - "category": "package-runtime-js", + "category": "package-unpacked-runtime-js", "owner": "@sheetwrite/core" }, "package.@sheetwrite/core.runtimeSourceBytes": { "actual": 0, "unit": "bytes", - "category": "package-runtime-source", + "category": "package-unpacked-runtime-source", "owner": "@sheetwrite/core" }, "package.@sheetwrite/core.tarballBytes": { - "actual": 529490, + "actual": 310038, "unit": "bytes", - "category": "package", + "category": "package-tarball", "owner": "@sheetwrite/core" }, "package.@sheetwrite/core.unpackedBytes": { - "actual": 2845386, + "actual": 1493547, "unit": "bytes", - "category": "package", + "category": "package-unpacked", "owner": "@sheetwrite/core" }, "package.@sheetwrite/core.wasmBytes": { "actual": 0, "unit": "bytes", - "category": "package-wasm", + "category": "package-unpacked-wasm", "owner": "@sheetwrite/core" }, "package.@sheetwrite/react.cssBytes": { "actual": 39, "unit": "bytes", - "category": "package-css", + "category": "package-unpacked-css", "owner": "@sheetwrite/react" }, "package.@sheetwrite/react.declarationsBytes": { "actual": 4910, "unit": "bytes", - "category": "package-declarations", + "category": "package-unpacked-declarations", "owner": "@sheetwrite/react" }, "package.@sheetwrite/react.fileCount": { "actual": 8, "unit": "count", - "category": "package", + "category": "package-files", "owner": "@sheetwrite/react" }, "package.@sheetwrite/react.mapsBytes": { "actual": 11885, "unit": "bytes", - "category": "package-maps", + "category": "package-unpacked-maps", "owner": "@sheetwrite/react" }, "package.@sheetwrite/react.metadataBytes": { "actual": 5243, "unit": "bytes", - "category": "package-metadata", + "category": "package-unpacked-metadata", "owner": "@sheetwrite/react" }, "package.@sheetwrite/react.runtimeJsBytes": { "actual": 10602, "unit": "bytes", - "category": "package-runtime-js", + "category": "package-unpacked-runtime-js", "owner": "@sheetwrite/react" }, "package.@sheetwrite/react.runtimeSourceBytes": { "actual": 0, "unit": "bytes", - "category": "package-runtime-source", + "category": "package-unpacked-runtime-source", "owner": "@sheetwrite/react" }, "package.@sheetwrite/react.tarballBytes": { - "actual": 9399, + "actual": 9398, "unit": "bytes", - "category": "package", + "category": "package-tarball", "owner": "@sheetwrite/react" }, "package.@sheetwrite/react.unpackedBytes": { "actual": 32679, "unit": "bytes", - "category": "package", + "category": "package-unpacked", "owner": "@sheetwrite/react" }, "package.@sheetwrite/react.wasmBytes": { "actual": 0, "unit": "bytes", - "category": "package-wasm", + "category": "package-unpacked-wasm", "owner": "@sheetwrite/react" }, "package.@sheetwrite/svelte.cssBytes": { "actual": 39, "unit": "bytes", - "category": "package-css", + "category": "package-unpacked-css", "owner": "@sheetwrite/svelte" }, "package.@sheetwrite/svelte.declarationsBytes": { "actual": 823, "unit": "bytes", - "category": "package-declarations", + "category": "package-unpacked-declarations", "owner": "@sheetwrite/svelte" }, "package.@sheetwrite/svelte.fileCount": { "actual": 10, "unit": "count", - "category": "package", + "category": "package-files", "owner": "@sheetwrite/svelte" }, "package.@sheetwrite/svelte.mapsBytes": { "actual": 0, "unit": "bytes", - "category": "package-maps", + "category": "package-unpacked-maps", "owner": "@sheetwrite/svelte" }, "package.@sheetwrite/svelte.metadataBytes": { "actual": 4572, "unit": "bytes", - "category": "package-metadata", + "category": "package-unpacked-metadata", "owner": "@sheetwrite/svelte" }, "package.@sheetwrite/svelte.runtimeJsBytes": { "actual": 0, "unit": "bytes", - "category": "package-runtime-js", + "category": "package-unpacked-runtime-js", "owner": "@sheetwrite/svelte" }, "package.@sheetwrite/svelte.runtimeSourceBytes": { "actual": 13485, "unit": "bytes", - "category": "package-runtime-source", + "category": "package-unpacked-runtime-source", "owner": "@sheetwrite/svelte" }, "package.@sheetwrite/svelte.tarballBytes": { "actual": 6354, "unit": "bytes", - "category": "package", + "category": "package-tarball", "owner": "@sheetwrite/svelte" }, "package.@sheetwrite/svelte.unpackedBytes": { "actual": 18919, "unit": "bytes", - "category": "package", + "category": "package-unpacked", "owner": "@sheetwrite/svelte" }, "package.@sheetwrite/svelte.wasmBytes": { "actual": 0, "unit": "bytes", - "category": "package-wasm", + "category": "package-unpacked-wasm", "owner": "@sheetwrite/svelte" }, "package.@sheetwrite/vue.cssBytes": { "actual": 39, "unit": "bytes", - "category": "package-css", + "category": "package-unpacked-css", "owner": "@sheetwrite/vue" }, "package.@sheetwrite/vue.declarationsBytes": { "actual": 7122, "unit": "bytes", - "category": "package-declarations", + "category": "package-unpacked-declarations", "owner": "@sheetwrite/vue" }, "package.@sheetwrite/vue.fileCount": { "actual": 8, "unit": "count", - "category": "package", + "category": "package-files", "owner": "@sheetwrite/vue" }, "package.@sheetwrite/vue.mapsBytes": { "actual": 14086, "unit": "bytes", - "category": "package-maps", + "category": "package-unpacked-maps", "owner": "@sheetwrite/vue" }, "package.@sheetwrite/vue.metadataBytes": { "actual": 4788, "unit": "bytes", - "category": "package-metadata", + "category": "package-unpacked-metadata", "owner": "@sheetwrite/vue" }, "package.@sheetwrite/vue.runtimeJsBytes": { "actual": 10543, "unit": "bytes", - "category": "package-runtime-js", + "category": "package-unpacked-runtime-js", "owner": "@sheetwrite/vue" }, "package.@sheetwrite/vue.runtimeSourceBytes": { "actual": 0, "unit": "bytes", - "category": "package-runtime-source", + "category": "package-unpacked-runtime-source", "owner": "@sheetwrite/vue" }, "package.@sheetwrite/vue.tarballBytes": { "actual": 10354, "unit": "bytes", - "category": "package", + "category": "package-tarball", "owner": "@sheetwrite/vue" }, "package.@sheetwrite/vue.unpackedBytes": { "actual": 36578, "unit": "bytes", - "category": "package", + "category": "package-unpacked", "owner": "@sheetwrite/vue" }, "package.@sheetwrite/vue.wasmBytes": { "actual": 0, "unit": "bytes", - "category": "package-wasm", + "category": "package-unpacked-wasm", "owner": "@sheetwrite/vue" }, "package.@sheetwrite/wasm.cssBytes": { "actual": 0, "unit": "bytes", - "category": "package-css", + "category": "package-unpacked-css", "owner": "@sheetwrite/wasm" }, "package.@sheetwrite/wasm.declarationsBytes": { - "actual": 46684, + "actual": 44812, "unit": "bytes", - "category": "package-declarations", + "category": "package-unpacked-declarations", "owner": "@sheetwrite/wasm" }, "package.@sheetwrite/wasm.fileCount": { "actual": 12, "unit": "count", - "category": "package", + "category": "package-files", "owner": "@sheetwrite/wasm" }, "package.@sheetwrite/wasm.mapsBytes": { "actual": 0, "unit": "bytes", - "category": "package-maps", + "category": "package-unpacked-maps", "owner": "@sheetwrite/wasm" }, "package.@sheetwrite/wasm.metadataBytes": { "actual": 3274, "unit": "bytes", - "category": "package-metadata", + "category": "package-unpacked-metadata", "owner": "@sheetwrite/wasm" }, "package.@sheetwrite/wasm.runtimeJsBytes": { - "actual": 71982, + "actual": 68999, "unit": "bytes", - "category": "package-runtime-js", + "category": "package-unpacked-runtime-js", "owner": "@sheetwrite/wasm" }, "package.@sheetwrite/wasm.runtimeSourceBytes": { "actual": 0, "unit": "bytes", - "category": "package-runtime-source", + "category": "package-unpacked-runtime-source", "owner": "@sheetwrite/wasm" }, "package.@sheetwrite/wasm.tarballBytes": { - "actual": 290846, + "actual": 294939, "unit": "bytes", - "category": "package", + "category": "package-tarball", "owner": "@sheetwrite/wasm" }, "package.@sheetwrite/wasm.unpackedBytes": { - "actual": 805430, + "actual": 812194, "unit": "bytes", - "category": "package", + "category": "package-unpacked", "owner": "@sheetwrite/wasm" }, "package.@sheetwrite/wasm.wasmBytes": { - "actual": 683490, + "actual": 695109, "unit": "bytes", - "category": "package-wasm", + "category": "package-unpacked-wasm", "owner": "@sheetwrite/wasm" }, "package.@sheetwrite/xlsx.cssBytes": { "actual": 0, "unit": "bytes", - "category": "package-css", + "category": "package-unpacked-css", "owner": "@sheetwrite/xlsx" }, "package.@sheetwrite/xlsx.declarationsBytes": { "actual": 9673, "unit": "bytes", - "category": "package-declarations", + "category": "package-unpacked-declarations", "owner": "@sheetwrite/xlsx" }, "package.@sheetwrite/xlsx.fileCount": { "actual": 59, "unit": "count", - "category": "package", + "category": "package-files", "owner": "@sheetwrite/xlsx" }, "package.@sheetwrite/xlsx.mapsBytes": { "actual": 215835, "unit": "bytes", - "category": "package-maps", + "category": "package-unpacked-maps", "owner": "@sheetwrite/xlsx" }, "package.@sheetwrite/xlsx.metadataBytes": { "actual": 4701, "unit": "bytes", - "category": "package-metadata", + "category": "package-unpacked-metadata", "owner": "@sheetwrite/xlsx" }, "package.@sheetwrite/xlsx.runtimeJsBytes": { "actual": 209662, "unit": "bytes", - "category": "package-runtime-js", + "category": "package-unpacked-runtime-js", "owner": "@sheetwrite/xlsx" }, "package.@sheetwrite/xlsx.runtimeSourceBytes": { "actual": 0, "unit": "bytes", - "category": "package-runtime-source", + "category": "package-unpacked-runtime-source", "owner": "@sheetwrite/xlsx" }, "package.@sheetwrite/xlsx.tarballBytes": { - "actual": 87519, + "actual": 87523, "unit": "bytes", - "category": "package", + "category": "package-tarball", "owner": "@sheetwrite/xlsx" }, "package.@sheetwrite/xlsx.unpackedBytes": { "actual": 439871, "unit": "bytes", - "category": "package", + "category": "package-unpacked", "owner": "@sheetwrite/xlsx" }, "package.@sheetwrite/xlsx.wasmBytes": { "actual": 0, "unit": "bytes", - "category": "package-wasm", + "category": "package-unpacked-wasm", "owner": "@sheetwrite/xlsx" } }, "packages": [ { "name": "@sheetwrite/core", - "tarballBytes": 529490, - "unpackedBytes": 2845386, - "fileCount": 335, + "tarballBytes": 310038, + "unpackedBytes": 1493547, + "fileCount": 174, "categories": { "css": 19374, - "declarations": 312860, - "maps": 1371090, - "metadata": 5096, - "runtime-js": 1136966, + "declarations": 316651, + "maps": 0, + "metadata": 5126, + "runtime-js": 1152396, "runtime-source": 0, "wasm": 0 } }, { "name": "@sheetwrite/react", - "tarballBytes": 9399, + "tarballBytes": 9398, "unpackedBytes": 32679, "fileCount": 8, "categories": { @@ -977,22 +1133,22 @@ }, { "name": "@sheetwrite/wasm", - "tarballBytes": 290846, - "unpackedBytes": 805430, + "tarballBytes": 294939, + "unpackedBytes": 812194, "fileCount": 12, "categories": { "css": 0, - "declarations": 46684, + "declarations": 44812, "maps": 0, "metadata": 3274, - "runtime-js": 71982, + "runtime-js": 68999, "runtime-source": 0, - "wasm": 683490 + "wasm": 695109 } }, { "name": "@sheetwrite/xlsx", - "tarballBytes": 87519, + "tarballBytes": 87523, "unpackedBytes": 439871, "fileCount": 59, "categories": { @@ -1010,44 +1166,44 @@ { "name": "core", "packageCount": 2, - "logicalBytes": 3650816, - "packages": ["@sheetwrite/core@0.3.0", "@sheetwrite/wasm@0.3.0"] + "logicalBytes": 2305741, + "packages": ["@sheetwrite/core@0.3.1", "@sheetwrite/wasm@0.3.1"] }, { "name": "core-xlsx", "packageCount": 4, - "logicalBytes": 4887429, + "logicalBytes": 3542354, "packages": [ - "@sheetwrite/core@0.3.0", - "@sheetwrite/wasm@0.3.0", - "@sheetwrite/xlsx@0.3.0", + "@sheetwrite/core@0.3.1", + "@sheetwrite/wasm@0.3.1", + "@sheetwrite/xlsx@0.3.1", "fflate@0.8.3" ] }, { "name": "react", "packageCount": 4, - "logicalBytes": 3855099, + "logicalBytes": 2510024, "packages": [ - "@sheetwrite/core@0.3.0", - "@sheetwrite/react@0.3.0", - "@sheetwrite/wasm@0.3.0", + "@sheetwrite/core@0.3.1", + "@sheetwrite/react@0.3.1", + "@sheetwrite/wasm@0.3.1", "react@19.2.7" ] }, { "name": "svelte", "packageCount": 23, - "logicalBytes": 8704597, + "logicalBytes": 7359522, "packages": [ "@jridgewell/gen-mapping@0.3.13", "@jridgewell/remapping@2.3.5", "@jridgewell/resolve-uri@3.1.2", "@jridgewell/sourcemap-codec@1.5.5", "@jridgewell/trace-mapping@0.3.31", - "@sheetwrite/core@0.3.0", - "@sheetwrite/svelte@0.3.0", - "@sheetwrite/wasm@0.3.0", + "@sheetwrite/core@0.3.1", + "@sheetwrite/svelte@0.3.1", + "@sheetwrite/wasm@0.3.1", "@sveltejs/acorn-typescript@1.0.11", "@types/estree@1.0.9", "@types/trusted-types@2.0.7", @@ -1068,16 +1224,16 @@ { "name": "vue", "packageCount": 26, - "logicalBytes": 20545448, + "logicalBytes": 19200373, "packages": [ "@babel/helper-string-parser@7.29.7", "@babel/helper-validator-identifier@7.29.7", "@babel/parser@7.29.7", "@babel/types@7.29.7", "@jridgewell/sourcemap-codec@1.5.5", - "@sheetwrite/core@0.3.0", - "@sheetwrite/vue@0.3.0", - "@sheetwrite/wasm@0.3.0", + "@sheetwrite/core@0.3.1", + "@sheetwrite/vue@0.3.1", + "@sheetwrite/wasm@0.3.1", "@vue/compiler-core@3.5.39", "@vue/compiler-dom@3.5.39", "@vue/compiler-sfc@3.5.39", @@ -1103,32 +1259,1880 @@ { "name": "next", "version": "15.5.9", - "assets": [ - { - "path": "test/bundler-fixtures/next/.next/static/1_Utdrb8GCiJfm5p160k2/_buildManifest.js", - "kind": "javascript", - "owner": "next-async", - "roles": ["next-async"], - "rawBytes": 506, - "gzipBytes": 299, - "brotliBytes": 257, - "sha256": "549fc1f6f226df947f859689a5217eeedc49d3ff876d9f364976a47ef4aad63d" + "provenance": { + "buildMode": "production", + "minified": true, + "minifier": { + "name": "next-swc", + "version": "15.5.9" }, + "externals": [], + "target": "browser", + "sourceMaps": "hidden-external", + "attributionMethod": "source-map-generated-spans-with-explicit-opaque-assets-v2" + }, + "attribution": [ { - "path": "test/bundler-fixtures/next/.next/static/1_Utdrb8GCiJfm5p160k2/_ssgManifest.js", - "kind": "javascript", - "owner": "next-async", - "roles": ["next-async"], - "rawBytes": 80, - "gzipBytes": 64, - "brotliBytes": 67, - "sha256": "678f6ce2cb80b1fe72fc67e7412be6e2ab6ada083111b64f7c40d35e3cba5e00" - }, + "name": "core-first-paint", + "entry": "app/attribution/page.tsx", + "eagerImports": ["@sheetwrite/core#createGrid", "@sheetwrite/core#initSheetwrite"], + "assets": [ + { + "path": "test/bundler-fixtures/next/.next/static/chunks/255-293e44824f2ec188.js", + "sha256": "4ba8283a24d0bdae7333962d50de4b47a38fdd3b3f6c5125103c8a4bd00d9fc6" + }, + { + "path": "test/bundler-fixtures/next/.next/static/chunks/4bd1b696-c023c6e3521b1417.js", + "sha256": "2c752a401191671d5bbbe7514f5828bbb0a7cc50bfbe77ddaf1b1ccba4412ed2" + }, + { + "path": "test/bundler-fixtures/next/.next/static/chunks/627-1a9744b6d0521212.js", + "sha256": "7d7055f1850a8b9f7eb8d036d39dfe00374fbccd27d0f0df9abb6f4041735516" + }, + { + "path": "test/bundler-fixtures/next/.next/static/chunks/app/attribution/page-0083d7b54fcdc367.js", + "sha256": "59bdc2f60cdb63d87fef293a25b61210a4001a24d5dd359dc73040266185c090" + }, + { + "path": "test/bundler-fixtures/next/.next/static/chunks/main-app-2a3d823058883a9c.js", + "sha256": "32491e30240ce7a785bf9d2b0f39e2f46561a717057bbe8190048c048ac6df69" + }, + { + "path": "test/bundler-fixtures/next/.next/static/chunks/polyfills-42372ed130431b0a.js", + "sha256": "0973c1d64c88adc8e3c950410cb58b288f72118d5965b78049438deb8f2f9683" + }, + { + "path": "test/bundler-fixtures/next/.next/static/chunks/webpack-eab35a71721f8873.js", + "sha256": "4d0142299f56123d5512a7aa6f5f8cd18690043c27648acbaa0c5c18dd06472e" + } + ], + "generatedBytes": 917158, + "modules": [ + { + "id": "@sheetwrite/core/dist/a1.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 1029, + "gzipBytes": 509, + "brotliBytes": 456 + }, + { + "id": "@sheetwrite/core/dist/aria-mirror.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 4306, + "gzipBytes": 1414, + "brotliBytes": 1221 + }, + { + "id": "@sheetwrite/core/dist/canvas-paint.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 10121, + "gzipBytes": 4238, + "brotliBytes": 3855 + }, + { + "id": "@sheetwrite/core/dist/canvas-renderer.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 1960, + "gzipBytes": 772, + "brotliBytes": 660 + }, + { + "id": "@sheetwrite/core/dist/cell-input.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 952, + "gzipBytes": 431, + "brotliBytes": 365 + }, + { + "id": "@sheetwrite/core/dist/clipboard-controller.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 12758, + "gzipBytes": 4395, + "brotliBytes": 3954 + }, + { + "id": "@sheetwrite/core/dist/clipboard.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 213, + "gzipBytes": 169, + "brotliBytes": 150 + }, + { + "id": "@sheetwrite/core/dist/column-index.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 824, + "gzipBytes": 416, + "brotliBytes": 381 + }, + { + "id": "@sheetwrite/core/dist/conditional-format.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 608, + "gzipBytes": 318, + "brotliBytes": 268 + }, + { + "id": "@sheetwrite/core/dist/context-menu.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 4607, + "gzipBytes": 1492, + "brotliBytes": 1291 + }, + { + "id": "@sheetwrite/core/dist/custom-editor.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 4708, + "gzipBytes": 1633, + "brotliBytes": 1415 + }, + { + "id": "@sheetwrite/core/dist/datasource-controller.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 24693, + "gzipBytes": 6832, + "brotliBytes": 6204 + }, + { + "id": "@sheetwrite/core/dist/date-serial.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 902, + "gzipBytes": 455, + "brotliBytes": 421 + }, + { + "id": "@sheetwrite/core/dist/delimited-text.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 4117, + "gzipBytes": 1645, + "brotliBytes": 1481 + }, + { + "id": "@sheetwrite/core/dist/document-controller.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 13204, + "gzipBytes": 3431, + "brotliBytes": 3100 + }, + { + "id": "@sheetwrite/core/dist/document-protocol.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 40545, + "gzipBytes": 10980, + "brotliBytes": 9654 + }, + { + "id": "@sheetwrite/core/dist/dom-overlay.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 10087, + "gzipBytes": 3471, + "brotliBytes": 3067 + }, + { + "id": "@sheetwrite/core/dist/editor.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 3046, + "gzipBytes": 1119, + "brotliBytes": 941 + }, + { + "id": "@sheetwrite/core/dist/errors.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 6790, + "gzipBytes": 2450, + "brotliBytes": 2192 + }, + { + "id": "@sheetwrite/core/dist/export.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 1815, + "gzipBytes": 1017, + "brotliBytes": 843 + }, + { + "id": "@sheetwrite/core/dist/fenwick.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 2834, + "gzipBytes": 928, + "brotliBytes": 849 + }, + { + "id": "@sheetwrite/core/dist/fill-series.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 523, + "gzipBytes": 331, + "brotliBytes": 310 + }, + { + "id": "@sheetwrite/core/dist/find-bar.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 2915, + "gzipBytes": 1023, + "brotliBytes": 875 + }, + { + "id": "@sheetwrite/core/dist/formula-assist.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 5108, + "gzipBytes": 2209, + "brotliBytes": 1915 + }, + { + "id": "@sheetwrite/core/dist/geometry-layout-controller.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 5818, + "gzipBytes": 1699, + "brotliBytes": 1556 + }, + { + "id": "@sheetwrite/core/dist/grid.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 56606, + "gzipBytes": 15173, + "brotliBytes": 13486 + }, + { + "id": "@sheetwrite/core/dist/history.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 3270, + "gzipBytes": 1040, + "brotliBytes": 950 + }, + { + "id": "@sheetwrite/core/dist/hyperlink.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 4644, + "gzipBytes": 1806, + "brotliBytes": 1637 + }, + { + "id": "@sheetwrite/core/dist/input-controller.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 12627, + "gzipBytes": 3778, + "brotliBytes": 3387 + }, + { + "id": "@sheetwrite/core/dist/mutation-revision-index.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 1869, + "gzipBytes": 712, + "brotliBytes": 637 + }, + { + "id": "@sheetwrite/core/dist/number-format.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 5329, + "gzipBytes": 2156, + "brotliBytes": 1955 + }, + { + "id": "@sheetwrite/core/dist/overlay-painter.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 12951, + "gzipBytes": 4287, + "brotliBytes": 3816 + }, + { + "id": "@sheetwrite/core/dist/reference.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 1298, + "gzipBytes": 521, + "brotliBytes": 462 + }, + { + "id": "@sheetwrite/core/dist/render-coordinator.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 8688, + "gzipBytes": 2444, + "brotliBytes": 2221 + }, + { + "id": "@sheetwrite/core/dist/resize.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 228, + "gzipBytes": 173, + "brotliBytes": 150 + }, + { + "id": "@sheetwrite/core/dist/resource-accounting.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 4672, + "gzipBytes": 1870, + "brotliBytes": 1668 + }, + { + "id": "@sheetwrite/core/dist/search-controller.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 4331, + "gzipBytes": 1423, + "brotliBytes": 1289 + }, + { + "id": "@sheetwrite/core/dist/search-replace.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 352, + "gzipBytes": 218, + "brotliBytes": 185 + }, + { + "id": "@sheetwrite/core/dist/selection.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 2605, + "gzipBytes": 883, + "brotliBytes": 804 + }, + { + "id": "@sheetwrite/core/dist/sheet-lifecycle.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 2876, + "gzipBytes": 985, + "brotliBytes": 890 + }, + { + "id": "@sheetwrite/core/dist/sheet-name.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 464, + "gzipBytes": 328, + "brotliBytes": 279 + }, + { + "id": "@sheetwrite/core/dist/sheet-tabs.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 18703, + "gzipBytes": 4906, + "brotliBytes": 4391 + }, + { + "id": "@sheetwrite/core/dist/store.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 11025, + "gzipBytes": 3328, + "brotliBytes": 2948 + }, + { + "id": "@sheetwrite/core/dist/store/data-engine.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 47620, + "gzipBytes": 12335, + "brotliBytes": 10976 + }, + { + "id": "@sheetwrite/core/dist/store/mutation-policy.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 7040, + "gzipBytes": 2221, + "brotliBytes": 2021 + }, + { + "id": "@sheetwrite/core/dist/store/paged-dirty-preflight.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 9331, + "gzipBytes": 3081, + "brotliBytes": 2824 + }, + { + "id": "@sheetwrite/core/dist/store/ranges.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 5063, + "gzipBytes": 1690, + "brotliBytes": 1531 + }, + { + "id": "@sheetwrite/core/dist/store/snapshot-codec.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 2928, + "gzipBytes": 1096, + "brotliBytes": 971 + }, + { + "id": "@sheetwrite/core/dist/store/view-state.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 7131, + "gzipBytes": 2451, + "brotliBytes": 2210 + }, + { + "id": "@sheetwrite/core/dist/store/window-reader.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 11577, + "gzipBytes": 3832, + "brotliBytes": 3500 + }, + { + "id": "@sheetwrite/core/dist/store/wire-tags.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 71, + "gzipBytes": 65, + "brotliBytes": 49 + }, + { + "id": "@sheetwrite/core/dist/style-actions.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 2661, + "gzipBytes": 1045, + "brotliBytes": 961 + }, + { + "id": "@sheetwrite/core/dist/style-dictionary.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 877, + "gzipBytes": 457, + "brotliBytes": 391 + }, + { + "id": "@sheetwrite/core/dist/toolbar.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 5815, + "gzipBytes": 2191, + "brotliBytes": 1909 + }, + { + "id": "@sheetwrite/core/dist/transaction-admission.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 530, + "gzipBytes": 284, + "brotliBytes": 256 + }, + { + "id": "@sheetwrite/core/dist/validation-editor.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 5121, + "gzipBytes": 1689, + "brotliBytes": 1446 + }, + { + "id": "@sheetwrite/core/dist/virtualization.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 468, + "gzipBytes": 244, + "brotliBytes": 214 + }, + { + "id": "@sheetwrite/core/dist/widget-theme.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 452, + "gzipBytes": 218, + "brotliBytes": 178 + }, + { + "id": "@sheetwrite/core/dist/workbook-table.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 3310, + "gzipBytes": 1367, + "brotliBytes": 1222 + }, + { + "id": "@sheetwrite/core/dist/worker-renderer.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 5988, + "gzipBytes": 2093, + "brotliBytes": 1819 + }, + { + "id": "@sheetwrite/wasm/loader-browser.mjs", + "owner": "@sheetwrite/wasm", + "attribution": "source-map", + "rawBytes": 112, + "gzipBytes": 119, + "brotliBytes": 88 + }, + { + "id": "@sheetwrite/wasm/loader-state.mjs", + "owner": "@sheetwrite/wasm", + "attribution": "source-map", + "rawBytes": 429, + "gzipBytes": 269, + "brotliBytes": 212 + }, + { + "id": "@sheetwrite/wasm/pkg/sheetwrite_wasm.js", + "owner": "@sheetwrite/wasm", + "attribution": "source-map", + "rawBytes": 20733, + "gzipBytes": 4548, + "brotliBytes": 3920 + }, + { + "id": "@swc/helpers/esm/_class_private_field_loose_base.js", + "owner": "@swc/helpers", + "attribution": "source-map", + "rawBytes": 241, + "gzipBytes": 205, + "brotliBytes": 155 + }, + { + "id": "@swc/helpers/esm/_class_private_field_loose_key.js", + "owner": "@swc/helpers", + "attribution": "source-map", + "rawBytes": 171, + "gzipBytes": 151, + "brotliBytes": 116 + }, + { + "id": "@swc/helpers/esm/_interop_require_default.js", + "owner": "@swc/helpers", + "attribution": "source-map", + "rawBytes": 102, + "gzipBytes": 116, + "brotliBytes": 90 + }, + { + "id": "@swc/helpers/esm/_interop_require_wildcard.js", + "owner": "@swc/helpers", + "attribution": "source-map", + "rawBytes": 775, + "gzipBytes": 417, + "brotliBytes": 380 + }, + { + "id": "fixture/app/attribution/page.tsx", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 145, + "gzipBytes": 149, + "brotliBytes": 120 + }, + { + "id": "fixture/src/client/add-base-path.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 390, + "gzipBytes": 267, + "brotliBytes": 223 + }, + { + "id": "fixture/src/client/app-bootstrap.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 864, + "gzipBytes": 509, + "brotliBytes": 397 + }, + { + "id": "fixture/src/client/app-build-id.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 500, + "gzipBytes": 270, + "brotliBytes": 228 + }, + { + "id": "fixture/src/client/app-call-server.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 606, + "gzipBytes": 364, + "brotliBytes": 306 + }, + { + "id": "fixture/src/client/app-find-source-map-url.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 298, + "gzipBytes": 207, + "brotliBytes": 168 + }, + { + "id": "fixture/src/client/app-globals.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 382, + "gzipBytes": 227, + "brotliBytes": 183 + }, + { + "id": "fixture/src/client/app-index.tsx", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 3060, + "gzipBytes": 1453, + "brotliBytes": 1266 + }, + { + "id": "fixture/src/client/app-next.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 344, + "gzipBytes": 246, + "brotliBytes": 210 + }, + { + "id": "fixture/src/client/app-webpack.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 488, + "gzipBytes": 300, + "brotliBytes": 242 + }, + { + "id": "fixture/src/client/assign-location.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 587, + "gzipBytes": 337, + "brotliBytes": 293 + }, + { + "id": "fixture/src/client/components/app-router-announcer.tsx", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 1477, + "gzipBytes": 757, + "brotliBytes": 614 + }, + { + "id": "fixture/src/client/components/app-router-headers.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 1470, + "gzipBytes": 603, + "brotliBytes": 512 + }, + { + "id": "fixture/src/client/components/app-router-instance.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 3620, + "gzipBytes": 1454, + "brotliBytes": 1251 + }, + { + "id": "fixture/src/client/components/app-router.tsx", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 6131, + "gzipBytes": 2392, + "brotliBytes": 2098 + }, + { + "id": "fixture/src/client/components/bfcache.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 682, + "gzipBytes": 360, + "brotliBytes": 322 + }, + { + "id": "fixture/src/client/components/builtin/global-error.tsx", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 1185, + "gzipBytes": 680, + "brotliBytes": 552 + }, + { + "id": "fixture/src/client/components/client-page.tsx", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 616, + "gzipBytes": 347, + "brotliBytes": 291 + }, + { + "id": "fixture/src/client/components/client-segment.tsx", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 506, + "gzipBytes": 312, + "brotliBytes": 259 + }, + { + "id": "fixture/src/client/components/error-boundary.tsx", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 1484, + "gzipBytes": 659, + "brotliBytes": 586 + }, + { + "id": "fixture/src/client/components/errors/graceful-degrade-boundary.tsx", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 1132, + "gzipBytes": 615, + "brotliBytes": 514 + }, + { + "id": "fixture/src/client/components/errors/root-error-boundary.tsx", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 711, + "gzipBytes": 396, + "brotliBytes": 358 + }, + { + "id": "fixture/src/client/components/forbidden.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 740, + "gzipBytes": 390, + "brotliBytes": 300 + }, + { + "id": "fixture/src/client/components/handle-isr-error.tsx", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 574, + "gzipBytes": 325, + "brotliBytes": 270 + }, + { + "id": "fixture/src/client/components/http-access-fallback/error-boundary.tsx", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 1774, + "gzipBytes": 807, + "brotliBytes": 697 + }, + { + "id": "fixture/src/client/components/http-access-fallback/http-access-fallback.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 1021, + "gzipBytes": 517, + "brotliBytes": 429 + }, + { + "id": "fixture/src/client/components/is-next-router-error.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 479, + "gzipBytes": 288, + "brotliBytes": 229 + }, + { + "id": "fixture/src/client/components/layout-router.tsx", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 5109, + "gzipBytes": 2275, + "brotliBytes": 1999 + }, + { + "id": "fixture/src/client/components/links.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 3252, + "gzipBytes": 1318, + "brotliBytes": 1141 + }, + { + "id": "fixture/src/client/components/match-segments.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 387, + "gzipBytes": 242, + "brotliBytes": 193 + }, + { + "id": "fixture/src/client/components/metadata/async-metadata.tsx", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 616, + "gzipBytes": 345, + "brotliBytes": 298 + }, + { + "id": "fixture/src/client/components/nav-failure-handler.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 723, + "gzipBytes": 388, + "brotliBytes": 306 + }, + { + "id": "fixture/src/client/components/navigation-untracked.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 500, + "gzipBytes": 281, + "brotliBytes": 236 + }, + { + "id": "fixture/src/client/components/navigation.react-server.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 1366, + "gzipBytes": 633, + "brotliBytes": 527 + }, + { + "id": "fixture/src/client/components/navigation.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 2338, + "gzipBytes": 935, + "brotliBytes": 834 + }, + { + "id": "fixture/src/client/components/not-found.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 576, + "gzipBytes": 335, + "brotliBytes": 270 + }, + { + "id": "fixture/src/client/components/promise-queue.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 1192, + "gzipBytes": 564, + "brotliBytes": 489 + }, + { + "id": "fixture/src/client/components/redirect-boundary.tsx", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 1132, + "gzipBytes": 569, + "brotliBytes": 492 + }, + { + "id": "fixture/src/client/components/redirect-error.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 732, + "gzipBytes": 431, + "brotliBytes": 357 + }, + { + "id": "fixture/src/client/components/redirect-status-code.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 530, + "gzipBytes": 289, + "brotliBytes": 224 + }, + { + "id": "fixture/src/client/components/redirect.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 1576, + "gzipBytes": 599, + "brotliBytes": 517 + }, + { + "id": "fixture/src/client/components/render-from-template-context.tsx", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 431, + "gzipBytes": 294, + "brotliBytes": 250 + }, + { + "id": "fixture/src/client/components/router-reducer/aliased-prefetch-navigations.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 2185, + "gzipBytes": 1114, + "brotliBytes": 1018 + }, + { + "id": "fixture/src/client/components/router-reducer/apply-flight-data.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 805, + "gzipBytes": 454, + "brotliBytes": 408 + }, + { + "id": "fixture/src/client/components/router-reducer/apply-router-state-patch-to-tree.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 1122, + "gzipBytes": 605, + "brotliBytes": 542 + }, + { + "id": "fixture/src/client/components/router-reducer/clear-cache-node-data-for-segment-path.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 1086, + "gzipBytes": 524, + "brotliBytes": 468 + }, + { + "id": "fixture/src/client/components/router-reducer/compute-changed-path.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 1716, + "gzipBytes": 882, + "brotliBytes": 781 + }, + { + "id": "fixture/src/client/components/router-reducer/create-href-from-url.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 547, + "gzipBytes": 283, + "brotliBytes": 238 + }, + { + "id": "fixture/src/client/components/router-reducer/create-initial-router-state.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 1612, + "gzipBytes": 918, + "brotliBytes": 815 + }, + { + "id": "fixture/src/client/components/router-reducer/create-router-cache-key.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 440, + "gzipBytes": 295, + "brotliBytes": 243 + }, + { + "id": "fixture/src/client/components/router-reducer/fetch-server-response.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 2889, + "gzipBytes": 1453, + "brotliBytes": 1262 + }, + { + "id": "fixture/src/client/components/router-reducer/fill-cache-with-new-subtree-data.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 1443, + "gzipBytes": 744, + "brotliBytes": 679 + }, + { + "id": "fixture/src/client/components/router-reducer/fill-lazy-items-till-leaf-with-head.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 1675, + "gzipBytes": 687, + "brotliBytes": 626 + }, + { + "id": "fixture/src/client/components/router-reducer/handle-mutable.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 1493, + "gzipBytes": 640, + "brotliBytes": 562 + }, + { + "id": "fixture/src/client/components/router-reducer/handle-segment-mismatch.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 370, + "gzipBytes": 252, + "brotliBytes": 203 + }, + { + "id": "fixture/src/client/components/router-reducer/invalidate-cache-below-flight-segmentpath.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 770, + "gzipBytes": 447, + "brotliBytes": 395 + }, + { + "id": "fixture/src/client/components/router-reducer/invalidate-cache-by-router-state.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 565, + "gzipBytes": 327, + "brotliBytes": 286 + }, + { + "id": "fixture/src/client/components/router-reducer/is-navigating-to-new-root-layout.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 612, + "gzipBytes": 331, + "brotliBytes": 282 + }, + { + "id": "fixture/src/client/components/router-reducer/ppr-navigations.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 5775, + "gzipBytes": 2259, + "brotliBytes": 2033 + }, + { + "id": "fixture/src/client/components/router-reducer/prefetch-cache-utils.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 3276, + "gzipBytes": 1274, + "brotliBytes": 1158 + }, + { + "id": "fixture/src/client/components/router-reducer/reducers/find-head-in-cache.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 861, + "gzipBytes": 478, + "brotliBytes": 416 + }, + { + "id": "fixture/src/client/components/router-reducer/reducers/get-segment-value.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 557, + "gzipBytes": 280, + "brotliBytes": 224 + }, + { + "id": "fixture/src/client/components/router-reducer/reducers/has-interception-route-in-current-tree.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 571, + "gzipBytes": 330, + "brotliBytes": 300 + }, + { + "id": "fixture/src/client/components/router-reducer/reducers/hmr-refresh-reducer.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 393, + "gzipBytes": 256, + "brotliBytes": 206 + }, + { + "id": "fixture/src/client/components/router-reducer/reducers/navigate-reducer.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 3416, + "gzipBytes": 1679, + "brotliBytes": 1491 + }, + { + "id": "fixture/src/client/components/router-reducer/reducers/prefetch-reducer.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 562, + "gzipBytes": 329, + "brotliBytes": 288 + }, + { + "id": "fixture/src/client/components/router-reducer/reducers/refresh-reducer.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 1666, + "gzipBytes": 961, + "brotliBytes": 854 + }, + { + "id": "fixture/src/client/components/router-reducer/reducers/restore-reducer.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 754, + "gzipBytes": 448, + "brotliBytes": 376 + }, + { + "id": "fixture/src/client/components/router-reducer/reducers/server-action-reducer.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 4330, + "gzipBytes": 2208, + "brotliBytes": 1905 + }, + { + "id": "fixture/src/client/components/router-reducer/reducers/server-patch-reducer.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 1155, + "gzipBytes": 643, + "brotliBytes": 565 + }, + { + "id": "fixture/src/client/components/router-reducer/refetch-inactive-parallel-segments.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 1318, + "gzipBytes": 681, + "brotliBytes": 611 + }, + { + "id": "fixture/src/client/components/router-reducer/router-reducer-types.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 884, + "gzipBytes": 440, + "brotliBytes": 370 + }, + { + "id": "fixture/src/client/components/router-reducer/router-reducer.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 1028, + "gzipBytes": 498, + "brotliBytes": 437 + }, + { + "id": "fixture/src/client/components/router-reducer/set-cache-busting-search-param.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 935, + "gzipBytes": 492, + "brotliBytes": 419 + }, + { + "id": "fixture/src/client/components/router-reducer/should-hard-navigate.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 451, + "gzipBytes": 312, + "brotliBytes": 259 + }, + { + "id": "fixture/src/client/components/segment-cache.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 1372, + "gzipBytes": 625, + "brotliBytes": 516 + }, + { + "id": "fixture/src/client/components/unauthorized.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 772, + "gzipBytes": 399, + "brotliBytes": 309 + }, + { + "id": "fixture/src/client/components/unrecognized-action-error.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 585, + "gzipBytes": 301, + "brotliBytes": 245 + }, + { + "id": "fixture/src/client/components/unresolved-thenable.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 304, + "gzipBytes": 213, + "brotliBytes": 172 + }, + { + "id": "fixture/src/client/components/unstable-rethrow.browser.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 528, + "gzipBytes": 317, + "brotliBytes": 269 + }, + { + "id": "fixture/src/client/components/unstable-rethrow.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 452, + "gzipBytes": 259, + "brotliBytes": 219 + }, + { + "id": "fixture/src/client/components/use-action-queue.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 824, + "gzipBytes": 470, + "brotliBytes": 405 + }, + { + "id": "fixture/src/client/flight-data-helpers.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 1123, + "gzipBytes": 613, + "brotliBytes": 524 + }, + { + "id": "fixture/src/client/has-base-path.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 479, + "gzipBytes": 273, + "brotliBytes": 222 + }, + { + "id": "fixture/src/client/normalize-trailing-slash.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 548, + "gzipBytes": 339, + "brotliBytes": 278 + }, + { + "id": "fixture/src/client/react-client-callbacks/error-boundary-callbacks.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 953, + "gzipBytes": 497, + "brotliBytes": 428 + }, + { + "id": "fixture/src/client/react-client-callbacks/on-recoverable-error.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 625, + "gzipBytes": 356, + "brotliBytes": 307 + }, + { + "id": "fixture/src/client/react-client-callbacks/report-global-error.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 368, + "gzipBytes": 242, + "brotliBytes": 190 + }, + { + "id": "fixture/src/client/remove-base-path.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 542, + "gzipBytes": 269, + "brotliBytes": 215 + }, + { + "id": "fixture/src/client/request/params.browser.prod.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 559, + "gzipBytes": 333, + "brotliBytes": 274 + }, + { + "id": "fixture/src/client/request/params.browser.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 463, + "gzipBytes": 269, + "brotliBytes": 218 + }, + { + "id": "fixture/src/client/request/search-params.browser.prod.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 473, + "gzipBytes": 315, + "brotliBytes": 260 + }, + { + "id": "fixture/src/client/request/search-params.browser.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 335, + "gzipBytes": 240, + "brotliBytes": 189 + }, + { + "id": "fixture/src/client/route-params.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 1756, + "gzipBytes": 840, + "brotliBytes": 730 + }, + { + "id": "fixture/src/client/set-attributes-from-props.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 961, + "gzipBytes": 542, + "brotliBytes": 436 + }, + { + "id": "fixture/src/lib/framework/boundary-components.tsx", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 760, + "gzipBytes": 325, + "brotliBytes": 278 + }, + { + "id": "fixture/src/lib/metadata/generate/icon-mark.tsx", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 198, + "gzipBytes": 174, + "brotliBytes": 156 + }, + { + "id": "fixture/src/shared/lib/app-router-context.shared-runtime.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 516, + "gzipBytes": 242, + "brotliBytes": 196 + }, + { + "id": "fixture/src/shared/lib/encode-uri-path.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 369, + "gzipBytes": 222, + "brotliBytes": 165 + }, + { + "id": "fixture/src/shared/lib/hash.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 472, + "gzipBytes": 289, + "brotliBytes": 237 + }, + { + "id": "fixture/src/shared/lib/head-manager-context.shared-runtime.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 257, + "gzipBytes": 201, + "brotliBytes": 155 + }, + { + "id": "fixture/src/shared/lib/hooks-client-context.shared-runtime.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 335, + "gzipBytes": 199, + "brotliBytes": 173 + }, + { + "id": "fixture/src/shared/lib/invariant-error.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 309, + "gzipBytes": 242, + "brotliBytes": 188 + }, + { + "id": "fixture/src/shared/lib/is-plain-object.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 565, + "gzipBytes": 280, + "brotliBytes": 231 + }, + { + "id": "fixture/src/shared/lib/is-thenable.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 246, + "gzipBytes": 183, + "brotliBytes": 146 + }, + { + "id": "fixture/src/shared/lib/lazy-dynamic/bailout-to-csr.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 439, + "gzipBytes": 306, + "brotliBytes": 247 + }, + { + "id": "fixture/src/shared/lib/page-path/ensure-leading-slash.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 347, + "gzipBytes": 213, + "brotliBytes": 167 + }, + { + "id": "fixture/src/shared/lib/router/utils/add-path-prefix.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 306, + "gzipBytes": 244, + "brotliBytes": 193 + }, + { + "id": "fixture/src/shared/lib/router/utils/app-paths.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 354, + "gzipBytes": 269, + "brotliBytes": 237 + }, + { + "id": "fixture/src/shared/lib/router/utils/cache-busting-search-param.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 361, + "gzipBytes": 243, + "brotliBytes": 205 + }, + { + "id": "fixture/src/shared/lib/router/utils/disable-smooth-scroll.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 552, + "gzipBytes": 322, + "brotliBytes": 258 + }, + { + "id": "fixture/src/shared/lib/router/utils/html-bots.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 522, + "gzipBytes": 372, + "brotliBytes": 316 + }, + { + "id": "fixture/src/shared/lib/router/utils/interception-routes.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 1509, + "gzipBytes": 647, + "brotliBytes": 554 + }, + { + "id": "fixture/src/shared/lib/router/utils/is-bot.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 570, + "gzipBytes": 309, + "brotliBytes": 267 + }, + { + "id": "fixture/src/shared/lib/router/utils/parse-path.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 463, + "gzipBytes": 278, + "brotliBytes": 222 + }, + { + "id": "fixture/src/shared/lib/router/utils/path-has-prefix.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 296, + "gzipBytes": 232, + "brotliBytes": 187 + }, + { + "id": "fixture/src/shared/lib/router/utils/remove-trailing-slash.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 347, + "gzipBytes": 212, + "brotliBytes": 167 + }, + { + "id": "fixture/src/shared/lib/segment-cache/segment-value-encoding.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 1077, + "gzipBytes": 534, + "brotliBytes": 452 + }, + { + "id": "fixture/src/shared/lib/segment.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 739, + "gzipBytes": 369, + "brotliBytes": 309 + }, + { + "id": "fixture/src/shared/lib/server-inserted-html.shared-runtime.tsx", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 227, + "gzipBytes": 174, + "brotliBytes": 137 + }, + { + "id": "fixture/src/shared/lib/server-reference-info.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 675, + "gzipBytes": 407, + "brotliBytes": 345 + }, + { + "id": "fixture/src/shared/lib/utils/reflect-utils.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 701, + "gzipBytes": 433, + "brotliBytes": 353 + }, + { + "id": "fixture/src/shared/lib/utils/warn-once.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 193, + "gzipBytes": 168, + "brotliBytes": 126 + }, + { + "id": "next:opaque/static/chunks/polyfills-42372ed130431b0a.js", + "owner": "next:opaque-framework", + "attribution": "opaque-asset", + "rawBytes": 112594, + "gzipBytes": 39520, + "brotliBytes": 35158 + }, + { + "id": "next:runtime", + "owner": "next:runtime", + "attribution": "source-map", + "rawBytes": 1185, + "gzipBytes": 421, + "brotliBytes": 389 + }, + { + "id": "next:runtime/webpack/bootstrap", + "owner": "next:runtime", + "attribution": "source-map", + "rawBytes": 171, + "gzipBytes": 146, + "brotliBytes": 116 + }, + { + "id": "next:runtime/webpack/runtime/chunk loaded", + "owner": "next:runtime", + "attribution": "source-map", + "rawBytes": 347, + "gzipBytes": 258, + "brotliBytes": 240 + }, + { + "id": "next:runtime/webpack/runtime/create fake namespace object", + "owner": "next:runtime", + "attribution": "source-map", + "rawBytes": 426, + "gzipBytes": 293, + "brotliBytes": 253 + }, + { + "id": "next:runtime/webpack/runtime/define property getters", + "owner": "next:runtime", + "attribution": "source-map", + "rawBytes": 101, + "gzipBytes": 110, + "brotliBytes": 84 + }, + { + "id": "next:runtime/webpack/runtime/ensure chunk", + "owner": "next:runtime", + "attribution": "source-map", + "rawBytes": 78, + "gzipBytes": 93, + "brotliBytes": 72 + }, + { + "id": "next:runtime/webpack/runtime/get javascript chunk filename", + "owner": "next:runtime", + "attribution": "source-map", + "rawBytes": 10, + "gzipBytes": 30, + "brotliBytes": 14 + }, + { + "id": "next:runtime/webpack/runtime/get mini-css chunk filename", + "owner": "next:runtime", + "attribution": "source-map", + "rawBytes": 17, + "gzipBytes": 37, + "brotliBytes": 21 + }, + { + "id": "next:runtime/webpack/runtime/global", + "owner": "next:runtime", + "attribution": "source-map", + "rawBytes": 161, + "gzipBytes": 122, + "brotliBytes": 85 + }, + { + "id": "next:runtime/webpack/runtime/hasOwnProperty shorthand", + "owner": "next:runtime", + "attribution": "source-map", + "rawBytes": 59, + "gzipBytes": 76, + "brotliBytes": 63 + }, + { + "id": "next:runtime/webpack/runtime/jsonp chunk loading", + "owner": "next:runtime", + "attribution": "source-map", + "rawBytes": 794, + "gzipBytes": 512, + "brotliBytes": 460 + }, + { + "id": "next:runtime/webpack/runtime/load script", + "owner": "next:runtime", + "attribution": "source-map", + "rawBytes": 739, + "gzipBytes": 445, + "brotliBytes": 350 + }, + { + "id": "next:runtime/webpack/runtime/make namespace object", + "owner": "next:runtime", + "attribution": "source-map", + "rawBytes": 167, + "gzipBytes": 130, + "brotliBytes": 109 + }, + { + "id": "next:runtime/webpack/runtime/publicPath", + "owner": "next:runtime", + "attribution": "source-map", + "rawBytes": 20, + "gzipBytes": 40, + "brotliBytes": 24 + }, + { + "id": "next:runtime/webpack/runtime/relative url", + "owner": "next:runtime", + "attribution": "source-map", + "rawBytes": 284, + "gzipBytes": 226, + "brotliBytes": 196 + }, + { + "id": "next:runtime/webpack/runtime/trusted types policy", + "owner": "next:runtime", + "attribution": "source-map", + "rawBytes": 173, + "gzipBytes": 146, + "brotliBytes": 114 + }, + { + "id": "next:runtime/webpack/runtime/trusted types script url", + "owner": "next:runtime", + "attribution": "source-map", + "rawBytes": 34, + "gzipBytes": 54, + "brotliBytes": 38 + }, + { + "id": "next/dist/build/deployment-id.js", + "owner": "next", + "attribution": "source-map", + "rawBytes": 290, + "gzipBytes": 183, + "brotliBytes": 142 + }, + { + "id": "next/dist/build/polyfills/polyfill-module.js", + "owner": "next", + "attribution": "source-map", + "rawBytes": 1540, + "gzipBytes": 664, + "brotliBytes": 585 + }, + { + "id": "next/dist/build/polyfills/process.js", + "owner": "next", + "attribution": "source-map", + "rawBytes": 293, + "gzipBytes": 218, + "brotliBytes": 181 + }, + { + "id": "next/dist/compiled/process/browser.js", + "owner": "next", + "attribution": "source-map", + "rawBytes": 2011, + "gzipBytes": 883, + "brotliBytes": 778 + }, + { + "id": "next/dist/compiled/react-dom/cjs/react-dom-client.production.js", + "owner": "next", + "attribution": "source-map", + "rawBytes": 172924, + "gzipBytes": 54186, + "brotliBytes": 46661 + }, + { + "id": "next/dist/compiled/react-dom/cjs/react-dom.production.js", + "owner": "next", + "attribution": "source-map", + "rawBytes": 3428, + "gzipBytes": 1282, + "brotliBytes": 1112 + }, + { + "id": "next/dist/compiled/react-dom/client.js", + "owner": "next", + "attribution": "source-map", + "rawBytes": 386, + "gzipBytes": 261, + "brotliBytes": 208 + }, + { + "id": "next/dist/compiled/react-dom/index.js", + "owner": "next", + "attribution": "source-map", + "rawBytes": 249, + "gzipBytes": 177, + "brotliBytes": 140 + }, + { + "id": "next/dist/compiled/react-server-dom-webpack/cjs/react-server-dom-webpack-client.browser.production.js", + "owner": "next", + "attribution": "source-map", + "rawBytes": 22974, + "gzipBytes": 7516, + "brotliBytes": 6675 + }, + { + "id": "next/dist/compiled/react-server-dom-webpack/client.browser.js", + "owner": "next", + "attribution": "source-map", + "rawBytes": 182, + "gzipBytes": 161, + "brotliBytes": 142 + }, + { + "id": "next/dist/compiled/react/cjs/react-jsx-runtime.production.js", + "owner": "next", + "attribution": "source-map", + "rawBytes": 480, + "gzipBytes": 323, + "brotliBytes": 282 + }, + { + "id": "next/dist/compiled/react/cjs/react.production.js", + "owner": "next", + "attribution": "source-map", + "rawBytes": 7404, + "gzipBytes": 2851, + "brotliBytes": 2553 + }, + { + "id": "next/dist/compiled/react/index.js", + "owner": "next", + "attribution": "source-map", + "rawBytes": 142, + "gzipBytes": 134, + "brotliBytes": 100 + }, + { + "id": "next/dist/compiled/react/jsx-runtime.js", + "owner": "next", + "attribution": "source-map", + "rawBytes": 46, + "gzipBytes": 66, + "brotliBytes": 50 + }, + { + "id": "next/dist/compiled/scheduler/cjs/scheduler.production.js", + "owner": "next", + "attribution": "source-map", + "rawBytes": 3582, + "gzipBytes": 1546, + "brotliBytes": 1376 + }, + { + "id": "next/dist/compiled/scheduler/index.js", + "owner": "next", + "attribution": "source-map", + "rawBytes": 137, + "gzipBytes": 133, + "brotliBytes": 99 + }, + { + "id": "next/dist/lib/framework/boundary-constants.js", + "owner": "next", + "attribution": "source-map", + "rawBytes": 555, + "gzipBytes": 285, + "brotliBytes": 237 + }, + { + "id": "next/dist/lib/is-error.js", + "owner": "next", + "attribution": "source-map", + "rawBytes": 756, + "gzipBytes": 388, + "brotliBytes": 343 + }, + { + "id": "next/dist/lib/require-instrumentation-client.js", + "owner": "next", + "attribution": "source-map", + "rawBytes": 137, + "gzipBytes": 132, + "brotliBytes": 107 + } + ] + } + ], + "assets": [ { "path": "test/bundler-fixtures/next/.next/static/chunks/255-293e44824f2ec188.js", "kind": "javascript", - "owner": "core-initial", - "roles": ["core-initial"], + "owner": "shared:core-initial+core-attribution-initial", + "roles": ["core-attribution-initial", "core-initial"], "rawBytes": 172538, "gzipBytes": 46193, "brotliBytes": 37794, @@ -1137,22 +3141,22 @@ { "path": "test/bundler-fixtures/next/.next/static/chunks/4bd1b696-c023c6e3521b1417.js", "kind": "javascript", - "owner": "core-initial", - "roles": ["core-initial"], + "owner": "shared:core-initial+core-attribution-initial", + "roles": ["core-attribution-initial", "core-initial"], "rawBytes": 173019, "gzipBytes": 54423, "brotliBytes": 46749, "sha256": "2c752a401191671d5bbbe7514f5828bbb0a7cc50bfbe77ddaf1b1ccba4412ed2" }, { - "path": "test/bundler-fixtures/next/.next/static/chunks/613-9a95c5fd3d41a111.js", + "path": "test/bundler-fixtures/next/.next/static/chunks/627-1a9744b6d0521212.js", "kind": "javascript", - "owner": "core-initial", - "roles": ["core-initial"], - "rawBytes": 465040, - "gzipBytes": 129007, - "brotliBytes": 105158, - "sha256": "f24f35974220a3f7e759454da6bd7f8397f8ee4e9eaf68ae633d9e26da61d477" + "owner": "shared:core-initial+core-attribution-initial", + "roles": ["core-attribution-initial", "core-initial"], + "rawBytes": 454451, + "gzipBytes": 125830, + "brotliBytes": 102637, + "sha256": "7d7055f1850a8b9f7eb8d036d39dfe00374fbccd27d0f0df9abb6f4041735516" }, { "path": "test/bundler-fixtures/next/.next/static/chunks/app/_not-found/page-52855012294ab07e.js", @@ -1164,6 +3168,16 @@ "brotliBytes": 824, "sha256": "7624a6a8350a8f7b193e867bff0d7b43e4cac83e0473b48f6eeeec8c81a0a20e" }, + { + "path": "test/bundler-fixtures/next/.next/static/chunks/app/attribution/page-0083d7b54fcdc367.js", + "kind": "javascript", + "owner": "core-attribution-initial", + "roles": ["core-attribution-initial"], + "rawBytes": 390, + "gzipBytes": 306, + "brotliBytes": 260, + "sha256": "59bdc2f60cdb63d87fef293a25b61210a4001a24d5dd359dc73040266185c090" + }, { "path": "test/bundler-fixtures/next/.next/static/chunks/app/layout-90900699d2a755fc.js", "kind": "javascript", @@ -1175,14 +3189,14 @@ "sha256": "e6531067acc892c8af51455cd419458fff745be856be3a8eba244a1701c82346" }, { - "path": "test/bundler-fixtures/next/.next/static/chunks/app/page-5c1aceb3849f1e5f.js", + "path": "test/bundler-fixtures/next/.next/static/chunks/app/page-3d492297a1dff877.js", "kind": "javascript", "owner": "core-initial", "roles": ["core-initial"], - "rawBytes": 1616, - "gzipBytes": 897, - "brotliBytes": 795, - "sha256": "b700af30152eaa970b49a97df048a530c5e831526b83f6b49883a062e0e98944" + "rawBytes": 17938, + "gzipBytes": 5801, + "brotliBytes": 5243, + "sha256": "81614eb490033a3dc9e35f62b4671b4fa95d5258d9d4140d1643e316461db5ff" }, { "path": "test/bundler-fixtures/next/.next/static/chunks/framework-acd67e14855de5a2.js", @@ -1207,8 +3221,8 @@ { "path": "test/bundler-fixtures/next/.next/static/chunks/main-app-2a3d823058883a9c.js", "kind": "javascript", - "owner": "core-initial", - "roles": ["core-initial"], + "owner": "shared:core-initial+core-attribution-initial", + "roles": ["core-attribution-initial", "core-initial"], "rawBytes": 557, "gzipBytes": 227, "brotliBytes": 206, @@ -1237,8 +3251,8 @@ { "path": "test/bundler-fixtures/next/.next/static/chunks/polyfills-42372ed130431b0a.js", "kind": "javascript", - "owner": "core-initial", - "roles": ["core-initial"], + "owner": "shared:core-initial+core-attribution-initial", + "roles": ["core-attribution-initial", "core-initial"], "rawBytes": 112594, "gzipBytes": 39494, "brotliBytes": 35158, @@ -1247,8 +3261,8 @@ { "path": "test/bundler-fixtures/next/.next/static/chunks/webpack-eab35a71721f8873.js", "kind": "javascript", - "owner": "core-initial", - "roles": ["core-initial"], + "owner": "shared:core-initial+core-attribution-initial", + "roles": ["core-attribution-initial", "core-initial"], "rawBytes": 3609, "gzipBytes": 1779, "brotliBytes": 1586, @@ -1257,38 +3271,618 @@ { "path": "test/bundler-fixtures/next/.next/static/css/d73f685bd1292336.css", "kind": "css", - "owner": "core-initial", - "roles": ["core-initial"], + "owner": "shared:core-initial+core-attribution-initial", + "roles": ["core-attribution-initial", "core-initial"], "rawBytes": 12299, "gzipBytes": 2508, "brotliBytes": 2177, "sha256": "b2f8184d1e3e64537e8797f6d160c486b692bcd4f17db59a2897448edceea96b" }, { - "path": "test/bundler-fixtures/next/.next/static/media/sheetwrite_wasm_bg.be3aaa14.wasm", + "path": "test/bundler-fixtures/next/.next/static/media/sheetwrite_wasm_bg.7ae8dcfe.wasm", "kind": "wasm", "owner": "core-initial", "roles": ["core-initial"], - "rawBytes": 683490, - "gzipBytes": 268969, - "brotliBytes": 208727, - "sha256": "e22de324008292f9aa4218f0ce7d748c0fcec41a99473ead9f04f56c7862af0f" + "rawBytes": 695109, + "gzipBytes": 274036, + "brotliBytes": 212236, + "sha256": "17ec82bad3101080fcfa79d0107ec0d77ae6254c031faafa67bcdd38351aa329" + }, + { + "path": "test/bundler-fixtures/next/.next/static/media/worker.a78c3889.js", + "kind": "javascript", + "owner": "next-async", + "roles": ["next-async"], + "rawBytes": 3801, + "gzipBytes": 1504, + "brotliBytes": 1357, + "sha256": "a87eee5b1f570bae57f32065ab637e39df1e55726965343894f52019ce49b625" + }, + { + "path": "test/bundler-fixtures/next/.next/static/yYv-gFAMjzbO5TghwUmzz/_buildManifest.js", + "kind": "javascript", + "owner": "next-async", + "roles": ["next-async"], + "rawBytes": 543, + "gzipBytes": 306, + "brotliBytes": 262, + "sha256": "9de87d6d2d392f8791be55e547eb3eb7764d034dcc178ed624a3c5a339f35807" }, { - "path": "test/bundler-fixtures/next/.next/static/media/worker.3657eea0.js", + "path": "test/bundler-fixtures/next/.next/static/yYv-gFAMjzbO5TghwUmzz/_ssgManifest.js", "kind": "javascript", "owner": "next-async", "roles": ["next-async"], - "rawBytes": 3495, - "gzipBytes": 1345, - "brotliBytes": 1232, - "sha256": "68432482a7a96857a76039090c9f4e4cb00657c2e110a61a09a56df820da4c61" + "rawBytes": 80, + "gzipBytes": 64, + "brotliBytes": 67, + "sha256": "678f6ce2cb80b1fe72fc67e7412be6e2ab6ada083111b64f7c40d35e3cba5e00" } ] }, { "name": "vite", "version": "7.2.2", + "provenance": { + "buildMode": "production", + "minified": true, + "minifier": { + "name": "esbuild", + "version": "0.25.12" + }, + "externals": [], + "target": "browser", + "sourceMaps": "hidden-external", + "attributionMethod": "source-map-generated-spans-with-explicit-opaque-assets-v2" + }, + "attribution": [ + { + "name": "core-first-paint", + "entry": "index.html", + "eagerImports": ["@sheetwrite/core#createGrid", "@sheetwrite/core#initSheetwrite"], + "assets": [ + { + "path": "test/bundler-fixtures/vite/dist/assets/core-DPVHP4sz.js", + "sha256": "904de4d3d6b449f209dd11fec78965b6c676974fa058e0f0fad6ad6a088cecda" + }, + { + "path": "test/bundler-fixtures/vite/dist/assets/export-C24LpoPY.js", + "sha256": "1168bb0be17944f7da2202c645a5851d5f22a77f469e8c1022d85f09aa902724" + }, + { + "path": "test/bundler-fixtures/vite/dist/assets/grid-CcxWrpW9.js", + "sha256": "6eeb4018b88f32c84f4c0a9bbd398ecc3b2b37182d64536afb71c116c8daea95" + }, + { + "path": "test/bundler-fixtures/vite/dist/assets/modulepreload-polyfill-B5Qt9EMX.js", + "sha256": "d2a32840421496e872ade591618d2fa5c33797605d1aec04301717e5a90757d0" + } + ], + "generatedBytes": 463910, + "modules": [ + { + "id": "@sheetwrite/core/dist/a1.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 1066, + "gzipBytes": 530, + "brotliBytes": 481 + }, + { + "id": "@sheetwrite/core/dist/aria-mirror.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 4335, + "gzipBytes": 1440, + "brotliBytes": 1249 + }, + { + "id": "@sheetwrite/core/dist/canvas-paint.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 10554, + "gzipBytes": 4438, + "brotliBytes": 4072 + }, + { + "id": "@sheetwrite/core/dist/canvas-renderer.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 1981, + "gzipBytes": 770, + "brotliBytes": 668 + }, + { + "id": "@sheetwrite/core/dist/cell-input.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 977, + "gzipBytes": 437, + "brotliBytes": 372 + }, + { + "id": "@sheetwrite/core/dist/clipboard-controller.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 13069, + "gzipBytes": 4549, + "brotliBytes": 4142 + }, + { + "id": "@sheetwrite/core/dist/clipboard.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 224, + "gzipBytes": 177, + "brotliBytes": 156 + }, + { + "id": "@sheetwrite/core/dist/column-index.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 835, + "gzipBytes": 418, + "brotliBytes": 380 + }, + { + "id": "@sheetwrite/core/dist/conditional-format.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 622, + "gzipBytes": 328, + "brotliBytes": 275 + }, + { + "id": "@sheetwrite/core/dist/context-menu.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 4639, + "gzipBytes": 1492, + "brotliBytes": 1302 + }, + { + "id": "@sheetwrite/core/dist/custom-editor.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 4724, + "gzipBytes": 1642, + "brotliBytes": 1410 + }, + { + "id": "@sheetwrite/core/dist/datasource-controller.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 25113, + "gzipBytes": 7073, + "brotliBytes": 6416 + }, + { + "id": "@sheetwrite/core/dist/date-serial.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 973, + "gzipBytes": 486, + "brotliBytes": 448 + }, + { + "id": "@sheetwrite/core/dist/delimited-text.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 4246, + "gzipBytes": 1700, + "brotliBytes": 1523 + }, + { + "id": "@sheetwrite/core/dist/document-controller.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 13346, + "gzipBytes": 3492, + "brotliBytes": 3157 + }, + { + "id": "@sheetwrite/core/dist/document-protocol.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 40921, + "gzipBytes": 11349, + "brotliBytes": 10060 + }, + { + "id": "@sheetwrite/core/dist/dom-overlay.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 10229, + "gzipBytes": 3532, + "brotliBytes": 3126 + }, + { + "id": "@sheetwrite/core/dist/editor.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 3091, + "gzipBytes": 1109, + "brotliBytes": 949 + }, + { + "id": "@sheetwrite/core/dist/errors.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 6920, + "gzipBytes": 2513, + "brotliBytes": 2262 + }, + { + "id": "@sheetwrite/core/dist/export.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 2898, + "gzipBytes": 1523, + "brotliBytes": 1290 + }, + { + "id": "@sheetwrite/core/dist/fenwick.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 2906, + "gzipBytes": 957, + "brotliBytes": 872 + }, + { + "id": "@sheetwrite/core/dist/fill-series.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 580, + "gzipBytes": 350, + "brotliBytes": 342 + }, + { + "id": "@sheetwrite/core/dist/find-bar.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 2963, + "gzipBytes": 1071, + "brotliBytes": 900 + }, + { + "id": "@sheetwrite/core/dist/formula-assist.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 5158, + "gzipBytes": 2225, + "brotliBytes": 1943 + }, + { + "id": "@sheetwrite/core/dist/geometry-layout-controller.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 5856, + "gzipBytes": 1710, + "brotliBytes": 1573 + }, + { + "id": "@sheetwrite/core/dist/grid.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 57258, + "gzipBytes": 15576, + "brotliBytes": 13854 + }, + { + "id": "@sheetwrite/core/dist/history.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 3349, + "gzipBytes": 1056, + "brotliBytes": 981 + }, + { + "id": "@sheetwrite/core/dist/hyperlink.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 4797, + "gzipBytes": 1870, + "brotliBytes": 1695 + }, + { + "id": "@sheetwrite/core/dist/input-controller.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 12824, + "gzipBytes": 3885, + "brotliBytes": 3476 + }, + { + "id": "@sheetwrite/core/dist/mutation-revision-index.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 1912, + "gzipBytes": 747, + "brotliBytes": 665 + }, + { + "id": "@sheetwrite/core/dist/number-format.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 5439, + "gzipBytes": 2239, + "brotliBytes": 2031 + }, + { + "id": "@sheetwrite/core/dist/overlay-painter.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 13233, + "gzipBytes": 4442, + "brotliBytes": 3960 + }, + { + "id": "@sheetwrite/core/dist/reference.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 1316, + "gzipBytes": 527, + "brotliBytes": 470 + }, + { + "id": "@sheetwrite/core/dist/render-coordinator.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 8753, + "gzipBytes": 2522, + "brotliBytes": 2289 + }, + { + "id": "@sheetwrite/core/dist/resize.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 260, + "gzipBytes": 197, + "brotliBytes": 177 + }, + { + "id": "@sheetwrite/core/dist/resource-accounting.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 4800, + "gzipBytes": 1924, + "brotliBytes": 1717 + }, + { + "id": "@sheetwrite/core/dist/search-controller.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 4391, + "gzipBytes": 1440, + "brotliBytes": 1305 + }, + { + "id": "@sheetwrite/core/dist/search-replace.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 360, + "gzipBytes": 227, + "brotliBytes": 197 + }, + { + "id": "@sheetwrite/core/dist/selection.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 2618, + "gzipBytes": 883, + "brotliBytes": 803 + }, + { + "id": "@sheetwrite/core/dist/sheet-lifecycle.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 2912, + "gzipBytes": 995, + "brotliBytes": 905 + }, + { + "id": "@sheetwrite/core/dist/sheet-name.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 474, + "gzipBytes": 332, + "brotliBytes": 306 + }, + { + "id": "@sheetwrite/core/dist/sheet-tabs.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 18847, + "gzipBytes": 4997, + "brotliBytes": 4476 + }, + { + "id": "@sheetwrite/core/dist/store.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 11105, + "gzipBytes": 3372, + "brotliBytes": 3017 + }, + { + "id": "@sheetwrite/core/dist/store/data-engine.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 48231, + "gzipBytes": 12979, + "brotliBytes": 11529 + }, + { + "id": "@sheetwrite/core/dist/store/mutation-policy.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 7060, + "gzipBytes": 2285, + "brotliBytes": 2082 + }, + { + "id": "@sheetwrite/core/dist/store/paged-dirty-preflight.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 9514, + "gzipBytes": 3260, + "brotliBytes": 2947 + }, + { + "id": "@sheetwrite/core/dist/store/ranges.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 5176, + "gzipBytes": 1704, + "brotliBytes": 1545 + }, + { + "id": "@sheetwrite/core/dist/store/snapshot-codec.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 2914, + "gzipBytes": 1166, + "brotliBytes": 1042 + }, + { + "id": "@sheetwrite/core/dist/store/view-state.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 7225, + "gzipBytes": 2532, + "brotliBytes": 2289 + }, + { + "id": "@sheetwrite/core/dist/store/window-reader.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 11909, + "gzipBytes": 4078, + "brotliBytes": 3697 + }, + { + "id": "@sheetwrite/core/dist/store/wire-tags.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 45, + "gzipBytes": 65, + "brotliBytes": 49 + }, + { + "id": "@sheetwrite/core/dist/style-actions.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 2693, + "gzipBytes": 1061, + "brotliBytes": 981 + }, + { + "id": "@sheetwrite/core/dist/style-dictionary.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 892, + "gzipBytes": 458, + "brotliBytes": 385 + }, + { + "id": "@sheetwrite/core/dist/toolbar.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 5855, + "gzipBytes": 2221, + "brotliBytes": 1945 + }, + { + "id": "@sheetwrite/core/dist/transaction-admission.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 580, + "gzipBytes": 304, + "brotliBytes": 269 + }, + { + "id": "@sheetwrite/core/dist/validation-editor.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 5176, + "gzipBytes": 1686, + "brotliBytes": 1461 + }, + { + "id": "@sheetwrite/core/dist/virtualization.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 506, + "gzipBytes": 255, + "brotliBytes": 224 + }, + { + "id": "@sheetwrite/core/dist/widget-theme.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 456, + "gzipBytes": 224, + "brotliBytes": 178 + }, + { + "id": "@sheetwrite/core/dist/workbook-table.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 3350, + "gzipBytes": 1369, + "brotliBytes": 1225 + }, + { + "id": "@sheetwrite/core/dist/worker-renderer.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 6183, + "gzipBytes": 2187, + "brotliBytes": 1919 + }, + { + "id": "@sheetwrite/wasm/loader-browser.mjs", + "owner": "@sheetwrite/wasm", + "attribution": "source-map", + "rawBytes": 111, + "gzipBytes": 120, + "brotliBytes": 102 + }, + { + "id": "@sheetwrite/wasm/loader-state.mjs", + "owner": "@sheetwrite/wasm", + "attribution": "source-map", + "rawBytes": 470, + "gzipBytes": 290, + "brotliBytes": 242 + }, + { + "id": "@sheetwrite/wasm/pkg/sheetwrite_wasm.js", + "owner": "@sheetwrite/wasm", + "attribution": "source-map", + "rawBytes": 21351, + "gzipBytes": 4679, + "brotliBytes": 4059 + }, + { + "id": "fixture/src/main.ts", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 100, + "gzipBytes": 108, + "brotliBytes": 88 + }, + { + "id": "vite:runtime", + "owner": "vite:runtime", + "attribution": "source-map", + "rawBytes": 1239, + "gzipBytes": 671, + "brotliBytes": 606 + } + ] + } + ], "assets": [ { "path": "test/bundler-fixtures/vite/dist/assets/core-BNMxIhFW.css", @@ -1301,54 +3895,54 @@ "sha256": "80b36f658c4b7f5cee2690544082216149a53601f3c8a81307d99b71fe6f8a19" }, { - "path": "test/bundler-fixtures/vite/dist/assets/core-Br9JSuUS.js", + "path": "test/bundler-fixtures/vite/dist/assets/core-DPVHP4sz.js", "kind": "javascript", "owner": "core-initial", "roles": ["core-initial"], "rawBytes": 220, "gzipBytes": 192, - "brotliBytes": 151, - "sha256": "14b2a44a246d1f987cd04d506201caa57d4c7deecf37f92054725a7159e639c6" + "brotliBytes": 152, + "sha256": "904de4d3d6b449f209dd11fec78965b6c676974fa058e0f0fad6ad6a088cecda" }, { - "path": "test/bundler-fixtures/vite/dist/assets/export-ng0TJT-k.js", + "path": "test/bundler-fixtures/vite/dist/assets/export-C24LpoPY.js", "kind": "javascript", "owner": "shared-initial", "roles": ["core-initial", "react-initial", "svelte-initial", "vue-initial"], - "rawBytes": 182245, - "gzipBytes": 50201, - "brotliBytes": 42752, - "sha256": "8500c3b214c1388477bce477ed99a9a9f80ea6b6832a56eee10d25266b3539ef" + "rawBytes": 184334, + "gzipBytes": 50884, + "brotliBytes": 43396, + "sha256": "1168bb0be17944f7da2202c645a5851d5f22a77f469e8c1022d85f09aa902724" }, { - "path": "test/bundler-fixtures/vite/dist/assets/grid-DbTceVj0.js", + "path": "test/bundler-fixtures/vite/dist/assets/grid-CcxWrpW9.js", "kind": "javascript", "owner": "shared-initial", "roles": ["core-initial", "react-initial", "svelte-initial", "vue-initial"], - "rawBytes": 274879, - "gzipBytes": 78335, - "brotliBytes": 65915, - "sha256": "515c4c42168e9000e00c98f703e6d9f7405874a4a0d45618fdf4e6974eda5698" + "rawBytes": 278645, + "gzipBytes": 79245, + "brotliBytes": 66601, + "sha256": "6eeb4018b88f32c84f4c0a9bbd398ecc3b2b37182d64536afb71c116c8daea95" }, { - "path": "test/bundler-fixtures/vite/dist/assets/index-Bsp4H2Vg.js", + "path": "test/bundler-fixtures/vite/dist/assets/index-DEwymOeA.js", "kind": "javascript", "owner": "xlsx-async", "roles": ["xlsx-async"], "rawBytes": 105506, "gzipBytes": 36963, - "brotliBytes": 32620, - "sha256": "8fcd327243286719da4cb85005cd9f767864682febd0e6da68ea21c35155e5bf" + "brotliBytes": 32602, + "sha256": "4c673c0f6e46efc55484c7143931570c68c3226c16978eb18ccb440cdd4501e1" }, { - "path": "test/bundler-fixtures/vite/dist/assets/lifecycle-Dd9gjJNa.js", + "path": "test/bundler-fixtures/vite/dist/assets/lifecycle-DovMfhQz.js", "kind": "javascript", "owner": "shared-initial", "roles": ["react-initial", "svelte-initial", "vue-initial"], "rawBytes": 13665, - "gzipBytes": 4569, - "brotliBytes": 4104, - "sha256": "969bbe0d673c0153fe2f6dc777b18cd7b8c9c66f8c68a4d567305c87a1a10dab" + "gzipBytes": 4570, + "brotliBytes": 4099, + "sha256": "9aadbe425054d77c1489fb73a73ad78e0ff09bdcfe2d95fbcf003489e39a8aab" }, { "path": "test/bundler-fixtures/vite/dist/assets/modulepreload-polyfill-B5Qt9EMX.js", @@ -1367,6 +3961,16 @@ "brotliBytes": 331, "sha256": "d2a32840421496e872ade591618d2fa5c33797605d1aec04301717e5a90757d0" }, + { + "path": "test/bundler-fixtures/vite/dist/assets/react-8Px03BgG.js", + "kind": "javascript", + "owner": "react-initial", + "roles": ["react-initial"], + "rawBytes": 190527, + "gzipBytes": 60324, + "brotliBytes": 52079, + "sha256": "382bd775172d273cb566605c0b98a700d006073564826c763d10a9dfd23218c1" + }, { "path": "test/bundler-fixtures/vite/dist/assets/react-bn7iQgEK.css", "kind": "css", @@ -1378,123 +3982,410 @@ "sha256": "f83ede118e63b7b6ac9011928df26934042997780b3d0469ec663f73b9853aa8" }, { - "path": "test/bundler-fixtures/vite/dist/assets/react-MPRmVc9a.js", - "kind": "javascript", - "owner": "react-initial", - "roles": ["react-initial"], - "rawBytes": 190527, - "gzipBytes": 60324, - "brotliBytes": 51997, - "sha256": "86326651cc067ac8c9737848ef7091bfe089a3bf2bd1893e54f5f61b225e1c0f" - }, - { - "path": "test/bundler-fixtures/vite/dist/assets/sheetwrite_wasm_bg-CpFKEODg.wasm", + "path": "test/bundler-fixtures/vite/dist/assets/sheetwrite_wasm_bg-DJDVXmlv.wasm", "kind": "wasm", "owner": "shared-initial", "roles": ["core-initial", "react-initial", "svelte-initial", "vue-initial"], - "rawBytes": 683490, - "gzipBytes": 268969, - "brotliBytes": 208727, - "sha256": "e22de324008292f9aa4218f0ce7d748c0fcec41a99473ead9f04f56c7862af0f" + "rawBytes": 695109, + "gzipBytes": 274036, + "brotliBytes": 212236, + "sha256": "17ec82bad3101080fcfa79d0107ec0d77ae6254c031faafa67bcdd38351aa329" }, { - "path": "test/bundler-fixtures/vite/dist/assets/svelte-CKlFB43x.js", + "path": "test/bundler-fixtures/vite/dist/assets/svelte-V1pmmKg4.js", "kind": "javascript", "owner": "svelte-initial", "roles": ["svelte-initial"], "rawBytes": 42135, "gzipBytes": 16295, - "brotliBytes": 14826, - "sha256": "c886e834857030e88041cbcffeb756abe3a336231b7440ec8a2c5f08ed16885a" + "brotliBytes": 14834, + "sha256": "99dc328c286108f7e5cd874f2b442cceb062a7ceaeb012ef2b3cb031260a4b2c" }, { - "path": "test/bundler-fixtures/vite/dist/assets/vue-BakzGof5.js", + "path": "test/bundler-fixtures/vite/dist/assets/vue-CcZwNgb4.js", "kind": "javascript", "owner": "vue-initial", "roles": ["vue-initial"], "rawBytes": 66456, "gzipBytes": 26046, - "brotliBytes": 23601, - "sha256": "a45dbe6644d59a1f44fe160ff2beb98114f0cbca7502b303c9ba7a6a63e1b629" + "brotliBytes": 23653, + "sha256": "117c707bc5477f6f63ab28108197ee65275325f3621d6353cfc60f2220a29777" }, { - "path": "test/bundler-fixtures/vite/dist/assets/worker-BAwzmSxl.js", + "path": "test/bundler-fixtures/vite/dist/assets/worker-B7mJ3QkU.js", "kind": "javascript", "owner": "worker-loader-initial", "roles": ["worker-loader-initial"], "rawBytes": 158, - "gzipBytes": 155, - "brotliBytes": 110, - "sha256": "3d93ba89b5c98860ead4289804270e7a7125c78ecdc0a66963b66c2cd3d4fbbb" + "gzipBytes": 152, + "brotliBytes": 134, + "sha256": "31c7b408f382717752f705e400d04d8dd9f87c3b412b38f7e7a41b90d8749098" }, { - "path": "test/bundler-fixtures/vite/dist/assets/worker-D699GEvh.js", + "path": "test/bundler-fixtures/vite/dist/assets/worker-jZA7ANVi.js", "kind": "javascript", "owner": "worker-async", "roles": ["worker-async"], - "rawBytes": 7337, - "gzipBytes": 1833, - "brotliBytes": 1632, - "sha256": "6f192d766bc5b31f639e9810317064c5f2f396ed46a6410fc84dd645251649ca" + "rawBytes": 7910, + "gzipBytes": 1992, + "brotliBytes": 1758, + "sha256": "bee28024b06c35206b58e978c099a024182a9f0cb8830e7e6dc3b1a2043682d7" }, { - "path": "test/bundler-fixtures/vite/dist/assets/xlsx-DbZE9eQm.js", + "path": "test/bundler-fixtures/vite/dist/assets/xlsx-BtccAyir.js", "kind": "javascript", "owner": "xlsx-loader-initial", "roles": ["xlsx-loader-initial"], "rawBytes": 1543, - "gzipBytes": 879, - "brotliBytes": 735, - "sha256": "521f85931a002649d7e2e1a6bad71a34c52a14808030bb134d02ab3e9c937d22" + "gzipBytes": 877, + "brotliBytes": 741, + "sha256": "58e2bfc1886487709ba59b34c37638a725180f99cd9015f9ee4e17e9617daca5" }, { - "path": "test/bundler-fixtures/vite/dist/recipe-worker-4zUNFFOW.js", + "path": "test/bundler-fixtures/vite/dist/recipe-worker-uE0NR-65.js", "kind": "javascript", "owner": "worker-async", "roles": ["worker-async"], - "rawBytes": 19485, - "gzipBytes": 7636, - "brotliBytes": 6974, - "sha256": "1f16ecc27a7d2ef610a36a998e15a17d617893b7e048c06c8a7d8f28f4337042" + "rawBytes": 19746, + "gzipBytes": 7771, + "brotliBytes": 7067, + "sha256": "5ca5b3437b07651a1e90c08b05035c5c550c69076c5d1cc9b4768ef6c3dc710a" } ] }, { "name": "webpack", "version": "5.102.1", + "provenance": { + "buildMode": "production", + "minified": true, + "minifier": { + "name": "terser-webpack-plugin", + "version": "5.6.1" + }, + "externals": [], + "target": "browser", + "sourceMaps": "hidden-external", + "attributionMethod": "source-map-generated-spans-with-explicit-opaque-assets-v2" + }, + "attribution": [ + { + "name": "core-first-paint", + "entry": "src/main.js", + "eagerImports": ["@sheetwrite/core#initSheetwrite"], + "assets": [ + { + "path": "test/bundler-fixtures/webpack/dist/main.js", + "sha256": "085a2abde87bc5fa8f082a3ab7c8f0f10b3bcd9247a7477ce2ae69de8f4e7c51" + } + ], + "generatedBytes": 28818, + "modules": [ + { + "id": "@sheetwrite/core/dist/errors.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 4870, + "gzipBytes": 1841, + "brotliBytes": 1620 + }, + { + "id": "@sheetwrite/core/dist/grid.js", + "owner": "@sheetwrite/core", + "attribution": "source-map", + "rawBytes": 98, + "gzipBytes": 101, + "brotliBytes": 74 + }, + { + "id": "@sheetwrite/wasm/loader-browser.mjs", + "owner": "@sheetwrite/wasm", + "attribution": "source-map", + "rawBytes": 94, + "gzipBytes": 108, + "brotliBytes": 87 + }, + { + "id": "@sheetwrite/wasm/loader-state.mjs", + "owner": "@sheetwrite/wasm", + "attribution": "source-map", + "rawBytes": 439, + "gzipBytes": 274, + "brotliBytes": 217 + }, + { + "id": "@sheetwrite/wasm/pkg/sheetwrite_wasm.js", + "owner": "@sheetwrite/wasm", + "attribution": "source-map", + "rawBytes": 21073, + "gzipBytes": 4585, + "brotliBytes": 3944 + }, + { + "id": "fixture/src/main.js", + "owner": "fixture", + "attribution": "source-map", + "rawBytes": 225, + "gzipBytes": 202, + "brotliBytes": 179 + }, + { + "id": "webpack:runtime", + "owner": "webpack:runtime", + "attribution": "source-map", + "rawBytes": 23, + "gzipBytes": 43, + "brotliBytes": 27 + }, + { + "id": "webpack:runtime/webpack/bootstrap", + "owner": "webpack:runtime", + "attribution": "source-map", + "rawBytes": 128, + "gzipBytes": 114, + "brotliBytes": 92 + }, + { + "id": "webpack:runtime/webpack/runtime/async module", + "owner": "webpack:runtime", + "attribution": "source-map", + "rawBytes": 946, + "gzipBytes": 521, + "brotliBytes": 485 + }, + { + "id": "webpack:runtime/webpack/runtime/define property getters", + "owner": "webpack:runtime", + "attribution": "source-map", + "rawBytes": 101, + "gzipBytes": 111, + "brotliBytes": 85 + }, + { + "id": "webpack:runtime/webpack/runtime/get javascript chunk filename", + "owner": "webpack:runtime", + "attribution": "source-map", + "rawBytes": 46, + "gzipBytes": 66, + "brotliBytes": 47 + }, + { + "id": "webpack:runtime/webpack/runtime/global", + "owner": "webpack:runtime", + "attribution": "source-map", + "rawBytes": 165, + "gzipBytes": 126, + "brotliBytes": 92 + }, + { + "id": "webpack:runtime/webpack/runtime/hasOwnProperty shorthand", + "owner": "webpack:runtime", + "attribution": "source-map", + "rawBytes": 59, + "gzipBytes": 76, + "brotliBytes": 63 + }, + { + "id": "webpack:runtime/webpack/runtime/jsonp chunk loading", + "owner": "webpack:runtime", + "attribution": "source-map", + "rawBytes": 71, + "gzipBytes": 84, + "brotliBytes": 61 + }, + { + "id": "webpack:runtime/webpack/runtime/publicPath", + "owner": "webpack:runtime", + "attribution": "source-map", + "rawBytes": 469, + "gzipBytes": 321, + "brotliBytes": 249 + }, + { + "id": "webpack:runtime/webpack/startup", + "owner": "webpack:runtime", + "attribution": "source-map", + "rawBytes": 11, + "gzipBytes": 31, + "brotliBytes": 15 + } + ] + } + ], "assets": [ { - "path": "test/bundler-fixtures/webpack/dist/assets/sheetwrite_wasm_bg-c2bcdba09f8dbe875a2b.wasm", + "path": "test/bundler-fixtures/webpack/dist/assets/sheetwrite_wasm_bg-e7d58d00f6347c2dfbf6.wasm", "kind": "wasm", "owner": "core-initial", "roles": ["core-initial"], - "rawBytes": 683490, - "gzipBytes": 268969, - "brotliBytes": 208727, - "sha256": "e22de324008292f9aa4218f0ce7d748c0fcec41a99473ead9f04f56c7862af0f" + "rawBytes": 695109, + "gzipBytes": 274036, + "brotliBytes": 212236, + "sha256": "17ec82bad3101080fcfa79d0107ec0d77ae6254c031faafa67bcdd38351aa329" }, { "path": "test/bundler-fixtures/webpack/dist/main.js", "kind": "javascript", "owner": "core-initial", "roles": ["core-initial"], - "rawBytes": 29855, - "gzipBytes": 7734, - "brotliBytes": 6765, - "sha256": "4ed915ffcfdad2404f919b9f2e331468386e3fcd88a584cc29a9305947f5807c" + "rawBytes": 28818, + "gzipBytes": 7593, + "brotliBytes": 6640, + "sha256": "085a2abde87bc5fa8f082a3ab7c8f0f10b3bcd9247a7477ce2ae69de8f4e7c51" }, { - "path": "test/bundler-fixtures/webpack/dist/worker-878-3756ccb083d655fdf3cc.js", + "path": "test/bundler-fixtures/webpack/dist/worker-566-d1e2cf43b18349bf337f.js", "kind": "javascript", "owner": "worker-async", "roles": ["worker-async"], - "rawBytes": 19245, - "gzipBytes": 7466, - "brotliBytes": 6781, - "sha256": "7fb1c6eefd60f0d45758724e7b57cac8268be95d3cb14e0419cc9288b6b7cd69" + "rawBytes": 19513, + "gzipBytes": 7608, + "brotliBytes": 6909, + "sha256": "1c5b998db3bdcf2997410c53e09753e50200b689fd3c6d412f4dcc97c99c6455" } ] } ], + "attributionFindings": { + "comparison": { + "entry": "core-first-paint", + "buildMode": "production", + "minified": true, + "externals": [], + "target": "browser", + "sourceMaps": "hidden-external", + "attributionMethod": "source-map-generated-spans-with-explicit-opaque-assets-v2", + "minifiers": { + "next": { + "name": "next-swc", + "version": "15.5.9" + }, + "vite": { + "name": "esbuild", + "version": "0.25.12" + } + }, + "fixtureAndConfigurationDifferences": [ + { + "bundler": "next", + "eagerImports": ["@sheetwrite/core#createGrid", "@sheetwrite/core#initSheetwrite"], + "nonSheetwriteRawBytes": 484154, + "opaqueFrameworkRawBytes": 112594 + }, + { + "bundler": "vite", + "eagerImports": ["@sheetwrite/core#createGrid", "@sheetwrite/core#initSheetwrite"], + "nonSheetwriteRawBytes": 23271, + "opaqueFrameworkRawBytes": 0 + } + ] + }, + "firstPaint": [ + { + "bundler": "next", + "generatedBytes": 917158, + "sheetwriteRawBytes": 433004, + "rootBarrelRawBytes": 0, + "eagerImports": ["@sheetwrite/core#createGrid", "@sheetwrite/core#initSheetwrite"] + }, + { + "bundler": "vite", + "generatedBytes": 463910, + "sheetwriteRawBytes": 440639, + "rootBarrelRawBytes": 0, + "eagerImports": ["@sheetwrite/core#createGrid", "@sheetwrite/core#initSheetwrite"] + } + ], + "subpaths": [ + { + "subpath": "sync", + "publicExportAdded": false, + "decision": "retain-root-export", + "reason": "Neither comparable first-paint graph retains this module, so a dedicated public subpath has no measured initial-bundle benefit.", + "retainedModules": [ + { + "bundler": "next", + "rawBytes": 0, + "brotliBytes": 0 + }, + { + "bundler": "vite", + "rawBytes": 0, + "brotliBytes": 0 + } + ], + "materialWinThreshold": { + "brotliBytes": 7680, + "percent": 10 + } + }, + { + "subpath": "collaboration", + "publicExportAdded": false, + "decision": "retain-root-export", + "reason": "Neither comparable first-paint graph retains this module, so a dedicated public subpath has no measured initial-bundle benefit.", + "retainedModules": [ + { + "bundler": "next", + "rawBytes": 0, + "brotliBytes": 0 + }, + { + "bundler": "vite", + "rawBytes": 0, + "brotliBytes": 0 + } + ], + "materialWinThreshold": { + "brotliBytes": 7680, + "percent": 10 + } + }, + { + "subpath": "rebase", + "publicExportAdded": false, + "decision": "retain-root-export", + "reason": "Neither comparable first-paint graph retains this module, so a dedicated public subpath has no measured initial-bundle benefit.", + "retainedModules": [ + { + "bundler": "next", + "rawBytes": 0, + "brotliBytes": 0 + }, + { + "bundler": "vite", + "rawBytes": 0, + "brotliBytes": 0 + } + ], + "materialWinThreshold": { + "brotliBytes": 7680, + "percent": 10 + } + }, + { + "subpath": "persistence", + "publicExportAdded": false, + "decision": "retain-root-export", + "reason": "Neither comparable first-paint graph retains this module, so a dedicated public subpath has no measured initial-bundle benefit.", + "retainedModules": [ + { + "bundler": "next", + "rawBytes": 0, + "brotliBytes": 0 + }, + { + "bundler": "vite", + "rawBytes": 0, + "brotliBytes": 0 + } + ], + "materialWinThreshold": { + "brotliBytes": 7680, + "percent": 10 + } + } + ] + }, "reproduction": "bun run size:report" } diff --git a/scripts/size-report.test.ts b/scripts/size-report.test.ts index 9579aa49..23893e1d 100644 --- a/scripts/size-report.test.ts +++ b/scripts/size-report.test.ts @@ -3,6 +3,16 @@ import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { + FIRST_PAINT_BROTLI_GATE_BYTES, + FIRST_PAINT_DEFERRED_MODULE, + firstPaintGate, + validateFirstPaintEvidence, +} from "./first-paint-evidence.js"; +import { + addPackageSizeMetrics, + assertAttributionAssetsFresh, + BUNDLER_SOURCE_MAP_MODE, + buildAttributionFindings, classifyPackedPath, compressedSizes, formatMetricDelta, @@ -13,6 +23,7 @@ import { SIZE_PROTOCOL_VERSION, summarizePack, validateBundlerEvidence, + validateComparableAttribution, validateSizeHistory, walkLogicalBytes, } from "./size-report.js"; @@ -39,15 +50,99 @@ function packJson(overrides: Record = {}): string { ]); } -function evidence(assets: unknown[]) { +function evidence( + assets: unknown[], + overrides: Record = {}, +): Record { + const initialPath = + (assets as Array<{ path?: unknown; kind?: unknown; roles?: unknown }>).find( + (asset) => + asset.kind === "javascript" && + Array.isArray(asset.roles) && + asset.roles.includes("core-initial"), + )?.path ?? "dist/main.js"; return { schemaVersion: SIZE_PROTOCOL_VERSION, bundler: "webpack", version: "5.102.1", assets, + provenance: { + buildMode: "production", + minified: true, + minifier: { name: "terser-webpack-plugin", version: "5.3.14" }, + externals: [], + target: "browser", + sourceMaps: BUNDLER_SOURCE_MAP_MODE, + attributionMethod: "source-map-generated-spans-with-explicit-opaque-assets-v2", + }, + attribution: [ + { + name: "core-first-paint", + entry: "src/main.js", + eagerImports: ["@sheetwrite/core#createGrid", "@sheetwrite/core#initSheetwrite"], + assets: [{ path: initialPath, sha256: "a".repeat(64) }], + generatedBytes: 10, + modules: [ + { + id: "@sheetwrite/core/dist/index.js", + owner: "@sheetwrite/core", + attribution: "source-map", + rawBytes: 10, + gzipBytes: 8, + brotliBytes: 6, + }, + ], + }, + ], + ...overrides, }; } +function comparableEvidence(bundler: "next" | "vite", overrides: Record = {}) { + const roles = + bundler === "next" + ? ["core-initial"] + : [ + "core-initial", + "react-initial", + "vue-initial", + "svelte-initial", + "worker-async", + "xlsx-async", + ]; + const assets = roles.map((role) => ({ + path: `dist/${role}.js`, + kind: "javascript", + owner: role, + roles: [role], + })); + assets.push({ + path: "dist/runtime.wasm", + kind: "wasm", + owner: "core-initial", + roles: ["core-initial"], + }); + return validateBundlerEvidence( + evidence(assets, { + bundler, + version: bundler === "next" ? "15.5.9" : "7.2.2", + provenance: { + buildMode: "production", + minified: true, + minifier: { + name: bundler === "next" ? "next-swc" : "esbuild", + version: bundler === "next" ? "15.5.9" : "0.25.12", + }, + externals: [], + target: "browser", + sourceMaps: BUNDLER_SOURCE_MAP_MODE, + attributionMethod: "source-map-generated-spans-with-explicit-opaque-assets-v2", + }, + ...overrides, + }), + ); +} + describe("npm pack parsing and classification", () => { it("parses a complete npm pack result and classifies packed bytes", () => { const result = parsePackJson(packJson()); @@ -62,6 +157,20 @@ describe("npm pack parsing and classification", () => { expect(classifyPackedPath("src/Grid.svelte")).toBe("runtime-source"); }); + it("rejects source maps in the published core package", () => { + const result = parsePackJson( + packJson({ + files: [ + { path: "dist/index.js.map", size: 2 }, + { path: "package.json", size: 1 }, + ], + }), + ); + expect(() => summarizePack("@sheetwrite/core", result)).toThrow( + "@sheetwrite/core published files must exclude source maps", + ); + }); + it("fails closed on malformed, duplicate, and unclassified pack data", () => { expect(() => parsePackJson("not-json")).toThrow("Malformed npm pack JSON"); expect(() => parsePackJson("[]")).toThrow("exactly one result"); @@ -83,6 +192,22 @@ describe("npm pack parsing and classification", () => { }); }); +describe("package size metric surfaces", () => { + it("labels transfer and install bytes separately from browser payload bytes", () => { + const metrics: Record = {}; + addPackageSizeMetrics(metrics, summarizePack("@sheetwrite/test", parsePackJson(packJson()))); + + expect(metrics["package.@sheetwrite/test.tarballBytes"]?.category).toBe("package-tarball"); + expect(metrics["package.@sheetwrite/test.unpackedBytes"]?.category).toBe("package-unpacked"); + expect(metrics["package.@sheetwrite/test.runtimeJsBytes"]?.category).toBe( + "package-unpacked-runtime-js", + ); + expect(Object.values(metrics).some((metric) => metric.category.startsWith("browser-"))).toBe( + false, + ); + }); +}); + describe("deterministic transfer and install bytes", () => { it("uses stable gzip and Brotli settings", () => { const input = new TextEncoder().encode("sheetwrite".repeat(100)); @@ -163,6 +288,95 @@ describe("manifest ownership", () => { ), ).toThrow("leaked into an initial entry"); }); + + it("rejects absent or incomplete module attribution", () => { + expect(() => validateBundlerEvidence(evidence(complete, { attribution: [] }))).toThrow( + "missing module attribution", + ); + const incomplete = evidence(complete); + const attribution = incomplete.attribution as Array<{ + modules: Array<{ owner?: string }>; + }>; + delete attribution[0]?.modules[0]?.owner; + expect(() => validateBundlerEvidence(incomplete)).toThrow("lacks ownership or sizes"); + }); + + it("requires explicit ownership for opaque framework assets", () => { + const opaque = evidence(complete); + const modules = opaque.attribution as Array<{ + modules: Array<{ id: string; owner: string; attribution: string }>; + }>; + const module = modules[0]?.modules[0]; + if (module === undefined) throw new Error("Attribution fixture lost its module"); + module.id = "webpack:opaque/polyfills.js"; + module.owner = "webpack:opaque-framework"; + module.attribution = "opaque-asset"; + expect(validateBundlerEvidence(opaque).attribution[0]?.modules[0]?.attribution).toBe( + "opaque-asset", + ); + module.owner = "fixture"; + expect(() => validateBundlerEvidence(opaque)).toThrow("invalid opaque ownership"); + }); + it("rejects attribution without an asset digest", () => { + const missingHash = evidence(complete); + const attributedAssets = missingHash.attribution as Array<{ + assets: Array<{ sha256?: string }>; + }>; + delete attributedAssets[0]?.assets[0]?.sha256; + expect(() => validateBundlerEvidence(missingHash)).toThrow("attribution entry 0 is incomplete"); + }); + + it("rejects stale same-length attribution by asset digest", () => { + const parsed = validateBundlerEvidence(evidence(complete)); + const path = parsed.attribution[0]?.assets[0]?.path; + if (path === undefined) throw new Error("Attribution fixture lost its initial asset"); + expect(() => + assertAttributionAssetsFresh(parsed, [{ path, rawBytes: 10, sha256: "a".repeat(64) }]), + ).not.toThrow(); + expect(() => + assertAttributionAssetsFresh(parsed, [{ path, rawBytes: 10, sha256: "b".repeat(64) }]), + ).toThrow("attribution asset SHA-256 changed"); + }); + + it("accepts controlled Next.js and Vite provenance and separates measured causes", () => { + const next = comparableEvidence("next"); + const vite = comparableEvidence("vite"); + expect(() => validateComparableAttribution([next, vite])).not.toThrow(); + + const findings = buildAttributionFindings([next, vite]); + expect(findings.comparison.minifiers).toEqual({ + next: { name: "next-swc", version: "15.5.9" }, + vite: { name: "esbuild", version: "0.25.12" }, + }); + expect(findings.firstPaint.map((entry) => entry.rootBarrelRawBytes)).toEqual([10, 10]); + expect(findings.subpaths.every((entry) => entry.decision === "retain-root-export")).toBe(true); + }); + + it("rejects incomparable modes, externals, and eager imports", () => { + const next = comparableEvidence("next"); + const vite = comparableEvidence("vite"); + const development = comparableEvidence("vite", { + provenance: { ...vite.provenance, buildMode: "development" }, + }); + expect(() => validateComparableAttribution([next, development])).toThrow( + "not a minified production browser build", + ); + const externalized = comparableEvidence("vite", { + provenance: { ...vite.provenance, externals: ["react"] }, + }); + expect(() => validateComparableAttribution([next, externalized])).toThrow( + "incomparable externals", + ); + const differentImports = comparableEvidence("vite", { + attribution: vite.attribution.map((entry) => ({ + ...entry, + eagerImports: ["@sheetwrite/core#createGrid"], + })), + }); + expect(() => validateComparableAttribution([next, differentImports])).toThrow( + "eager imports differ", + ); + }); }); describe("release size history", () => { @@ -200,3 +414,148 @@ describe("release size history", () => { ).toThrow("Duplicate size release"); }); }); + +function firstPaintEvidence(): Record { + const fixture = (bundler: "next" | "vite") => ({ + bundler, + version: bundler === "next" ? "15.5.9" : "7.2.2", + provenance: { + buildMode: "production", + minified: true, + minifier: { + name: bundler === "next" ? "next-swc" : "esbuild", + version: bundler === "next" ? "15.5.9" : "0.25.12", + }, + externals: [], + target: "browser", + sourceMaps: BUNDLER_SOURCE_MAP_MODE, + attributionMethod: "source-map-generated-spans-with-explicit-opaque-assets-v2", + }, + eagerImports: ["@sheetwrite/core#createGrid", "@sheetwrite/core#initSheetwrite"], + baseline: { + initialJavaScript: { rawBytes: 200_000, gzipBytes: 70_000, brotliBytes: 60_000 }, + timing: { samplesMs: new Array(10).fill(20), medianMs: 20, p95Ms: 20 }, + checksum: "a".repeat(64), + }, + candidate: { + initialJavaScript: { rawBytes: 160_000, gzipBytes: 56_000, brotliBytes: 50_000 }, + timing: { samplesMs: new Array(10).fill(19), medianMs: 19, p95Ms: 19 }, + checksum: "a".repeat(64), + deferredChunk: "assets/clipboard-controller.js", + interaction: { + module: FIRST_PAINT_DEFERRED_MODULE, + loaded: true, + successOutcome: "empty", + errorOutcome: "unsupported", + rejected: false, + }, + }, + delta: { + rawBytes: 40_000, + gzipBytes: 14_000, + brotliBytes: 10_000, + brotliPercent: (10_000 / 60_000) * 100, + }, + }); + return { + schemaVersion: SIZE_PROTOCOL_VERSION, + kind: "first-paint-counterfactual", + candidate: { + fixtureOnly: true, + shipping: false, + deferredModules: [FIRST_PAINT_DEFERRED_MODULE], + }, + thresholds: { + brotliBytes: FIRST_PAINT_BROTLI_GATE_BYTES, + percent: 10, + timing: "median-and-p95-no-slower", + }, + fixtures: [fixture("next"), fixture("vite")], + }; +} + +describe("first-paint counterfactual evidence", () => { + it("admits complete comparable evidence only when both size and timing gates pass", () => { + const evidence = firstPaintEvidence(); + expect(validateFirstPaintEvidence(evidence).fixtures).toHaveLength(2); + expect(firstPaintGate(evidence)).toEqual({ admitted: true, reasons: [] }); + }); + + it("rejects missing size, timing, checksum, and interaction fields", () => { + for (const field of ["initialJavaScript", "timing", "checksum"] as const) { + const evidence = structuredClone(firstPaintEvidence()); + const baseline = (evidence.fixtures as Array>)[0]!.baseline as Record< + string, + unknown + >; + delete baseline[field]; + expect(() => validateFirstPaintEvidence(evidence)).toThrow(); + } + const evidence = structuredClone(firstPaintEvidence()); + delete ( + (evidence.fixtures as Array>)[0]!.candidate as Record + ).interaction; + expect(() => validateFirstPaintEvidence(evidence)).toThrow("interaction evidence is missing"); + }); + + it("rejects incomparable semantics, provenance, samples, and recorded deltas", () => { + const mutations = [ + (evidence: Record) => { + const fixture = (evidence.fixtures as Array>)[0]!; + (fixture.candidate as Record).checksum = "b".repeat(64); + }, + (evidence: Record) => { + const fixture = (evidence.fixtures as Array>)[0]!; + (fixture.provenance as Record).buildMode = "development"; + }, + (evidence: Record) => { + const fixture = (evidence.fixtures as Array>)[0]!; + const candidate = fixture.candidate as Record; + (candidate.timing as Record).samplesMs = [19]; + }, + (evidence: Record) => { + const fixture = (evidence.fixtures as Array>)[0]!; + (fixture.delta as Record).brotliBytes = 9_999; + }, + ]; + for (const mutate of mutations) { + const evidence = structuredClone(firstPaintEvidence()); + mutate(evidence); + expect(() => validateFirstPaintEvidence(evidence)).toThrow(); + } + }); + + it("fails closed on either insufficient reduction or slower first paint", () => { + const evidence = firstPaintEvidence(); + const vite = (evidence.fixtures as Array>)[1]!; + const baseline = vite.baseline as Record; + const candidate = vite.candidate as Record; + candidate.initialJavaScript = { + rawBytes: 210_000, + gzipBytes: 75_000, + brotliBytes: 62_000, + }; + vite.delta = { + rawBytes: -10_000, + gzipBytes: -5_000, + brotliBytes: -2_000, + brotliPercent: (-2_000 / 60_000) * 100, + }; + candidate.timing = { + samplesMs: new Array(10).fill(21), + medianMs: 21, + p95Ms: 21, + }; + baseline.timing = { + samplesMs: new Array(10).fill(20), + medianMs: 20, + p95Ms: 20, + }; + const decision = firstPaintGate(evidence); + expect(decision.admitted).toBe(false); + expect(decision.reasons).toContain("vite Brotli reduction is below 7680 bytes"); + expect(decision.reasons).toContain("vite Brotli reduction is below 10%"); + expect(decision.reasons).toContain("vite candidate median first paint is slower"); + expect(decision.reasons).toContain("vite candidate p95 first paint is slower"); + }); +}); diff --git a/scripts/size-report.ts b/scripts/size-report.ts index 612abb22..6d96d1e0 100644 --- a/scripts/size-report.ts +++ b/scripts/size-report.ts @@ -12,14 +12,19 @@ import { writeFile, } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { basename, join, relative, resolve, sep } from "node:path"; +import { basename, dirname, join, relative, resolve, sep } from "node:path"; import { brotliCompressSync, constants, gzipSync } from "node:zlib"; -import { readReleaseManifestDigest, verifyReleaseArtifacts } from "./release-artifacts.js"; +import { + assertPublishedFilePolicy, + readReleaseManifestDigest, + verifyReleaseArtifacts, +} from "./release-artifacts.js"; import { bindCanonicalTarballIntegrities } from "./release-lock-integrity.mjs"; -export const SIZE_PROTOCOL_VERSION = 1; +export const SIZE_PROTOCOL_VERSION = 2; export const SIZE_TOOL_NAME = "sheetwrite-delivery-size"; -export const SIZE_TOOL_VERSION = "1.0.0"; +export const SIZE_TOOL_VERSION = "2.0.0"; +export const BUNDLER_SOURCE_MAP_MODE = "hidden-external"; export type MetricUnit = "bytes" | "count"; export type PackedCategory = @@ -69,12 +74,45 @@ export interface BundlerAsset { owner: string; roles: string[]; } +export interface BundlerProvenance { + buildMode: string; + minified: boolean; + minifier: { name: string; version: string }; + externals: string[]; + target: string; + sourceMaps: string; + attributionMethod: string; +} + +export interface BundlerModuleAttribution { + id: string; + owner: string; + attribution: "source-map" | "opaque-asset"; + rawBytes: number; + gzipBytes: number; + brotliBytes: number; +} +export interface BundlerAttributedAsset { + path: string; + sha256: string; +} + +export interface BundlerEntryAttribution { + name: string; + entry: string; + eagerImports: string[]; + assets: BundlerAttributedAsset[]; + generatedBytes: number; + modules: BundlerModuleAttribution[]; +} export interface BundlerEvidence { schemaVersion: number; bundler: "next" | "vite" | "webpack"; version: string; assets: BundlerAsset[]; + provenance: BundlerProvenance; + attribution: BundlerEntryAttribution[]; } export interface PackageReport { @@ -98,6 +136,43 @@ export interface AssetReport extends BundlerAsset { brotliBytes: number; sha256: string; } +export interface AttributionFindings { + comparison: { + entry: "core-first-paint"; + buildMode: string; + minified: true; + externals: string[]; + target: string; + sourceMaps: string; + attributionMethod: string; + minifiers: Record<"next" | "vite", { name: string; version: string }>; + fixtureAndConfigurationDifferences: Array<{ + bundler: "next" | "vite"; + eagerImports: string[]; + nonSheetwriteRawBytes: number; + opaqueFrameworkRawBytes: number; + }>; + }; + firstPaint: Array<{ + bundler: "next" | "vite"; + generatedBytes: number; + sheetwriteRawBytes: number; + rootBarrelRawBytes: number; + eagerImports: string[]; + }>; + subpaths: Array<{ + subpath: "sync" | "collaboration" | "rebase" | "persistence"; + publicExportAdded: false; + decision: "retain-root-export" | "requires-isolated-subpath-proof"; + reason: string; + retainedModules: Array<{ + bundler: "next" | "vite"; + rawBytes: number; + brotliBytes: number; + }>; + materialWinThreshold: { brotliBytes: 7680; percent: 10 }; + }>; +} export interface SizeReport { schemaVersion: number; @@ -112,8 +187,11 @@ export interface SizeReport { bundlers: Array<{ name: BundlerEvidence["bundler"]; version: string; + provenance: BundlerProvenance; + attribution: BundlerEntryAttribution[]; assets: AssetReport[]; }>; + attributionFindings: AttributionFindings; reproduction: string; } @@ -157,6 +235,9 @@ const requiredBundlerRoles: Record file.path), + ); const categories: Record = { css: 0, declarations: 0, @@ -319,7 +404,11 @@ export function validateBundlerEvidence(value: unknown): BundlerEvidence { if (!(["next", "vite", "webpack"] as unknown[]).includes(candidate.bundler)) { throw new Error(`Unknown bundler evidence owner: ${String(candidate.bundler)}`); } - if (typeof candidate.version !== "string" || !Array.isArray(candidate.assets)) { + if ( + typeof candidate.version !== "string" || + candidate.version.length === 0 || + !Array.isArray(candidate.assets) + ) { throw new Error("Bundler evidence has invalid version or assets"); } const bundler = candidate.bundler as BundlerEvidence["bundler"]; @@ -368,7 +457,299 @@ export function validateBundlerEvidence(value: unknown): BundlerEvidence { throw new Error(`${bundler} async asset ${asset.path} leaked into an initial entry`); } } - return { schemaVersion: SIZE_PROTOCOL_VERSION, bundler, version: candidate.version, assets }; + + if (candidate.provenance === null || typeof candidate.provenance !== "object") { + throw new Error(`${bundler} is missing build provenance`); + } + const provenanceValue = candidate.provenance as Record; + const minifierValue = provenanceValue.minifier; + if ( + typeof provenanceValue.buildMode !== "string" || + provenanceValue.buildMode.length === 0 || + typeof provenanceValue.minified !== "boolean" || + minifierValue === null || + typeof minifierValue !== "object" || + typeof (minifierValue as Record).name !== "string" || + (minifierValue as Record).name === "" || + typeof (minifierValue as Record).version !== "string" || + (minifierValue as Record).version === "" || + !Array.isArray(provenanceValue.externals) || + !provenanceValue.externals.every( + (external) => typeof external === "string" && external.length > 0, + ) || + typeof provenanceValue.target !== "string" || + provenanceValue.target.length === 0 || + provenanceValue.sourceMaps !== BUNDLER_SOURCE_MAP_MODE || + provenanceValue.attributionMethod !== ATTRIBUTION_METHOD + ) { + throw new Error(`${bundler} build provenance is malformed or unsupported`); + } + const externals = provenanceValue.externals as string[]; + if (new Set(externals).size !== externals.length) { + throw new Error(`${bundler} build provenance contains duplicate externals`); + } + const provenance: BundlerProvenance = { + buildMode: provenanceValue.buildMode, + minified: provenanceValue.minified, + minifier: { + name: (minifierValue as Record).name as string, + version: (minifierValue as Record).version as string, + }, + externals: [...externals].sort(), + target: provenanceValue.target, + sourceMaps: provenanceValue.sourceMaps, + attributionMethod: provenanceValue.attributionMethod, + }; + + if (!Array.isArray(candidate.attribution) || candidate.attribution.length === 0) { + throw new Error(`${bundler} is missing module attribution`); + } + const assetByPath = new Map(assets.map((asset) => [asset.path, asset])); + const seenEntries = new Set(); + const attribution = candidate.attribution.map((value, entryIndex) => { + if (value === null || typeof value !== "object") { + throw new Error(`${bundler} attribution entry ${entryIndex} is malformed`); + } + const entry = value as Record; + if ( + typeof entry.name !== "string" || + entry.name.length === 0 || + typeof entry.entry !== "string" || + entry.entry.length === 0 || + !Array.isArray(entry.eagerImports) || + entry.eagerImports.length === 0 || + !entry.eagerImports.every( + (specifier) => typeof specifier === "string" && specifier.length > 0, + ) || + !Array.isArray(entry.assets) || + entry.assets.length === 0 || + !entry.assets.every( + (asset) => + asset !== null && + typeof asset === "object" && + typeof (asset as Record).path === "string" && + ((asset as Record).path as string).length > 0 && + typeof (asset as Record).sha256 === "string" && + /^[a-f0-9]{64}$/.test((asset as Record).sha256 as string), + ) || + !isNonNegativeInteger(entry.generatedBytes) || + entry.generatedBytes === 0 || + !Array.isArray(entry.modules) || + entry.modules.length === 0 + ) { + throw new Error(`${bundler} attribution entry ${entryIndex} is incomplete`); + } + if (seenEntries.has(entry.name)) { + throw new Error(`${bundler} contains duplicate attribution entry ${entry.name}`); + } + seenEntries.add(entry.name); + const attributedAssets = (entry.assets as Array>).map((asset) => ({ + path: asset.path as string, + sha256: asset.sha256 as string, + })); + const attributedPaths = attributedAssets.map((asset) => asset.path); + if (new Set(attributedPaths).size !== attributedPaths.length) { + throw new Error(`${bundler} ${entry.name} contains duplicate attributed assets`); + } + for (const { path } of attributedAssets) { + const asset = assetByPath.get(path); + if (asset === undefined || asset.kind !== "javascript") { + throw new Error(`${bundler} ${entry.name} attributes unknown JavaScript asset ${path}`); + } + } + const seenModules = new Set(); + const modules = entry.modules.map((value, moduleIndex) => { + if (value === null || typeof value !== "object") { + throw new Error(`${bundler} ${entry.name} module ${moduleIndex} is malformed`); + } + const module = value as Record; + if ( + typeof module.id !== "string" || + module.id.length === 0 || + typeof module.owner !== "string" || + module.owner.length === 0 || + module.owner === "unclassified" || + !(module.attribution === "source-map" || module.attribution === "opaque-asset") || + !isNonNegativeInteger(module.rawBytes) || + module.rawBytes === 0 || + !isNonNegativeInteger(module.gzipBytes) || + module.gzipBytes === 0 || + !isNonNegativeInteger(module.brotliBytes) || + module.brotliBytes === 0 + ) { + throw new Error(`${bundler} ${entry.name} module ${moduleIndex} lacks ownership or sizes`); + } + const opaqueIdentity = + module.owner === `${bundler}:opaque-framework` && + (module.id as string).startsWith(`${bundler}:opaque/`); + if ((module.attribution === "opaque-asset") !== opaqueIdentity) { + throw new Error( + `${bundler} ${entry.name} module ${moduleIndex} has invalid opaque ownership`, + ); + } + if (seenModules.has(module.id)) { + throw new Error(`${bundler} ${entry.name} contains duplicate module ${module.id}`); + } + seenModules.add(module.id); + return { + id: module.id, + owner: module.owner, + attribution: module.attribution, + rawBytes: module.rawBytes, + gzipBytes: module.gzipBytes, + brotliBytes: module.brotliBytes, + } as BundlerModuleAttribution; + }); + const attributedBytes = modules.reduce((sum, module) => sum + module.rawBytes, 0); + if (attributedBytes !== entry.generatedBytes) { + throw new Error( + `${bundler} ${entry.name} module bytes ${attributedBytes} do not equal generated bytes ${entry.generatedBytes}`, + ); + } + return { + name: entry.name, + entry: entry.entry, + eagerImports: [...new Set(entry.eagerImports as string[])].sort(), + assets: attributedAssets.sort((left, right) => left.path.localeCompare(right.path)), + generatedBytes: entry.generatedBytes, + modules: modules.sort((left, right) => left.id.localeCompare(right.id)), + } as BundlerEntryAttribution; + }); + if (!seenEntries.has(COMPARABLE_ENTRY)) { + throw new Error(`${bundler} is missing ${COMPARABLE_ENTRY} module attribution`); + } + return { + schemaVersion: SIZE_PROTOCOL_VERSION, + bundler, + version: candidate.version, + assets, + provenance, + attribution, + }; +} + +export function validateComparableAttribution(evidence: readonly BundlerEvidence[]): void { + const byBundler = new Map(evidence.map((entry) => [entry.bundler, entry])); + const next = byBundler.get("next"); + const vite = byBundler.get("vite"); + if (next === undefined || vite === undefined) { + throw new Error("Comparable attribution requires both Next.js and Vite evidence"); + } + for (const entry of [next, vite]) { + if ( + entry.provenance.buildMode !== "production" || + entry.provenance.minified !== true || + entry.provenance.target !== "browser" || + entry.provenance.sourceMaps !== BUNDLER_SOURCE_MAP_MODE || + entry.provenance.attributionMethod !== ATTRIBUTION_METHOD + ) { + throw new Error(`${entry.bundler} attribution is not a minified production browser build`); + } + } + for (const field of [ + "buildMode", + "minified", + "target", + "sourceMaps", + "attributionMethod", + ] as const) { + if (next.provenance[field] !== vite.provenance[field]) { + throw new Error(`Next.js and Vite attribution differ in ${field}`); + } + } + if (JSON.stringify(next.provenance.externals) !== JSON.stringify(vite.provenance.externals)) { + throw new Error("Next.js and Vite attribution use incomparable externals"); + } + const nextEntry = next.attribution.find((entry) => entry.name === COMPARABLE_ENTRY); + const viteEntry = vite.attribution.find((entry) => entry.name === COMPARABLE_ENTRY); + if (nextEntry === undefined || viteEntry === undefined) { + throw new Error(`Comparable attribution is missing ${COMPARABLE_ENTRY}`); + } + if (JSON.stringify(nextEntry.eagerImports) !== JSON.stringify(viteEntry.eagerImports)) { + throw new Error("Next.js and Vite first-paint eager imports differ"); + } +} +export function buildAttributionFindings( + evidence: readonly BundlerEvidence[], +): AttributionFindings { + validateComparableAttribution(evidence); + const comparable = (bundler: "next" | "vite") => { + const owner = evidence.find((entry) => entry.bundler === bundler); + const entry = owner?.attribution.find((candidate) => candidate.name === COMPARABLE_ENTRY); + if (owner === undefined || entry === undefined) { + throw new Error(`Missing ${bundler} ${COMPARABLE_ENTRY} attribution`); + } + return { owner, entry }; + }; + const next = comparable("next"); + const vite = comparable("vite"); + const pairs = [next, vite] as const; + const firstPaint = pairs.map(({ owner, entry }) => ({ + bundler: owner.bundler as "next" | "vite", + generatedBytes: entry.generatedBytes, + sheetwriteRawBytes: entry.modules + .filter((module) => module.owner === "@sheetwrite/core") + .reduce((sum, module) => sum + module.rawBytes, 0), + rootBarrelRawBytes: entry.modules + .filter( + (module) => + module.owner === "@sheetwrite/core" && + /(?:^|\/)(?:dist\/index\.js|src\/index\.ts)$/.test(module.id), + ) + .reduce((sum, module) => sum + module.rawBytes, 0), + eagerImports: entry.eagerImports, + })); + const subpaths: AttributionFindings["subpaths"] = SUBPATH_CANDIDATES.map((subpath) => { + const modulePattern = new RegExp(`(?:^|/)(?:dist/${subpath}\\.js|src/${subpath}\\.ts)$`); + const retainedModules = pairs.map(({ owner, entry }) => { + const modules = entry.modules.filter( + (module) => module.owner === "@sheetwrite/core" && modulePattern.test(module.id), + ); + return { + bundler: owner.bundler as "next" | "vite", + rawBytes: modules.reduce((sum, module) => sum + module.rawBytes, 0), + brotliBytes: modules.reduce((sum, module) => sum + module.brotliBytes, 0), + }; + }); + const retained = retainedModules.some((measurement) => measurement.rawBytes > 0); + return { + subpath, + publicExportAdded: false, + decision: retained ? "requires-isolated-subpath-proof" : "retain-root-export", + reason: retained + ? "The module is present in a comparable first-paint graph, but attribution alone does not prove the counterfactual bundle saving required for a public subpath." + : "Neither comparable first-paint graph retains this module, so a dedicated public subpath has no measured initial-bundle benefit.", + retainedModules, + materialWinThreshold: { brotliBytes: 7680, percent: 10 }, + }; + }); + return { + comparison: { + entry: COMPARABLE_ENTRY, + buildMode: next.owner.provenance.buildMode, + minified: true, + externals: next.owner.provenance.externals, + target: next.owner.provenance.target, + sourceMaps: next.owner.provenance.sourceMaps, + attributionMethod: next.owner.provenance.attributionMethod, + minifiers: { + next: next.owner.provenance.minifier, + vite: vite.owner.provenance.minifier, + }, + fixtureAndConfigurationDifferences: pairs.map(({ owner, entry }) => ({ + bundler: owner.bundler as "next" | "vite", + eagerImports: entry.eagerImports, + nonSheetwriteRawBytes: entry.modules + .filter((module) => module.owner !== "@sheetwrite/core") + .reduce((sum, module) => sum + module.rawBytes, 0), + opaqueFrameworkRawBytes: entry.modules + .filter((module) => module.attribution === "opaque-asset") + .reduce((sum, module) => sum + module.rawBytes, 0), + })), + }, + firstPaint, + subpaths, + }; } function processEnvironment(): Record { @@ -420,6 +801,31 @@ function rewriteWorkspaceRanges( ); } +async function copyManifestEntry( + sourceRoot: string, + packageRoot: string, + entry: string, +): Promise { + if (!/[*?[\]{}]/u.test(entry)) { + await cp(join(sourceRoot, entry), join(packageRoot, entry), { recursive: true }); + return; + } + const matches = Array.from( + new Bun.Glob(entry).scanSync({ + cwd: sourceRoot, + dot: true, + onlyFiles: true, + followSymlinks: false, + }), + ); + if (matches.length === 0) throw new Error(`Package file pattern matched nothing: ${entry}`); + for (const path of matches) { + const target = join(packageRoot, path); + await mkdir(dirname(target), { recursive: true }); + await cp(join(sourceRoot, path), target); + } +} + async function packPackages(temporaryRoot: string): Promise { const stageRoot = join(temporaryRoot, "stage"); const tarballRoot = join(temporaryRoot, "tarballs"); @@ -444,7 +850,7 @@ async function packPackages(temporaryRoot: string): Promise { const packageRoot = join(stageRoot, basename(directory)); await mkdir(packageRoot, { recursive: true }); for (const path of sourceManifest.files ?? []) { - await cp(join(sourceRoot, path), join(packageRoot, path), { recursive: true }); + await copyManifestEntry(sourceRoot, packageRoot, path); } await cp(join(repositoryRoot, "LICENSE"), join(packageRoot, "LICENSE")); await cp(join(sourceRoot, "README.md"), join(packageRoot, "README.md")); @@ -604,6 +1010,7 @@ async function loadBundlerEvidence( const path = join(bundlerEvidenceRoot, `${bundler}.json`); evidence.push(validateBundlerEvidence(await readJson(path))); } + validateComparableAttribution(evidence); return evidence; } @@ -620,11 +1027,82 @@ async function reportAsset(asset: BundlerAsset): Promise { sha256: createHash("sha256").update(content).digest("hex"), }; } +export function assertAttributionAssetsFresh( + evidence: Pick, + assets: readonly Pick[], +): void { + const assetByPath = new Map(assets.map((asset) => [asset.path, asset])); + for (const entry of evidence.attribution) { + let generatedBytes = 0; + for (const attributed of entry.assets) { + const asset = assetByPath.get(attributed.path); + if (asset === undefined) { + throw new Error( + `${evidence.bundler} ${entry.name} attribution asset disappeared: ${attributed.path}`, + ); + } + if (asset.sha256 !== attributed.sha256) { + throw new Error( + `${evidence.bundler} ${entry.name} attribution asset SHA-256 changed: ${attributed.path}`, + ); + } + generatedBytes += asset.rawBytes; + } + if (generatedBytes !== entry.generatedBytes) { + throw new Error( + `${evidence.bundler} ${entry.name} generated bytes changed after attribution`, + ); + } + } +} function metricSegment(value: string): string { return value.replace(/[^a-zA-Z0-9]+(.)/g, (_, next: string) => next.toUpperCase()); } +export function addPackageSizeMetrics( + metrics: Record, + report: PackageReport, +): void { + const prefix = `package.${report.name}`; + addMetric( + metrics, + `${prefix}.tarballBytes`, + report.tarballBytes, + "bytes", + "package-tarball", + report.name, + ); + addMetric( + metrics, + `${prefix}.unpackedBytes`, + report.unpackedBytes, + "bytes", + "package-unpacked", + report.name, + ); + addMetric( + metrics, + `${prefix}.fileCount`, + report.fileCount, + "count", + "package-files", + report.name, + ); + for (const [category, bytes] of Object.entries(report.categories).sort(([left], [right]) => + left.localeCompare(right), + )) { + addMetric( + metrics, + `${prefix}.${metricSegment(category)}Bytes`, + bytes, + "bytes", + `package-unpacked-${category}`, + report.name, + ); + } +} + async function buildSizeReport( reuseBundlers: boolean, artifactDirectory?: string, @@ -666,54 +1144,24 @@ async function buildSizeReport( } const bundlerEvidence = await loadBundlerEvidence(reuseBundlers, artifactDirectory); + const attributionFindings = buildAttributionFindings(bundlerEvidence); const bundlers: SizeReport["bundlers"] = []; for (const evidence of bundlerEvidence) { const assets = await Promise.all(evidence.assets.map(reportAsset)); assets.sort((left, right) => left.path.localeCompare(right.path)); - bundlers.push({ name: evidence.bundler, version: evidence.version, assets }); + assertAttributionAssetsFresh(evidence, assets); + bundlers.push({ + name: evidence.bundler, + version: evidence.version, + provenance: evidence.provenance, + attribution: evidence.attribution, + assets, + }); } bundlers.sort((left, right) => left.name.localeCompare(right.name)); const metrics: Record = {}; - for (const entry of packedPackages) { - const prefix = `package.${entry.name}`; - addMetric( - metrics, - `${prefix}.tarballBytes`, - entry.report.tarballBytes, - "bytes", - "package", - entry.name, - ); - addMetric( - metrics, - `${prefix}.unpackedBytes`, - entry.report.unpackedBytes, - "bytes", - "package", - entry.name, - ); - addMetric( - metrics, - `${prefix}.fileCount`, - entry.report.fileCount, - "count", - "package", - entry.name, - ); - for (const [category, bytes] of Object.entries(entry.report.categories).sort( - ([left], [right]) => left.localeCompare(right), - )) { - addMetric( - metrics, - `${prefix}.${metricSegment(category)}Bytes`, - bytes, - "bytes", - `package-${category}`, - entry.name, - ); - } - } + for (const entry of packedPackages) addPackageSizeMetrics(metrics, entry.report); for (const closure of closures) { const prefix = `closure.${closure.name}`; addMetric( @@ -783,6 +1231,44 @@ async function buildSizeReport( } } } + for (const entry of attributionFindings.firstPaint) { + addMetric( + metrics, + `attribution.${entry.bundler}.coreFirstPaint.sheetwriteRawBytes`, + entry.sheetwriteRawBytes, + "bytes", + "browser-module-attribution", + `${entry.bundler}:@sheetwrite/core`, + ); + addMetric( + metrics, + `attribution.${entry.bundler}.coreFirstPaint.rootBarrelRawBytes`, + entry.rootBarrelRawBytes, + "bytes", + "browser-module-attribution", + `${entry.bundler}:@sheetwrite/core`, + ); + } + for (const candidate of attributionFindings.subpaths) { + for (const retained of candidate.retainedModules) { + addMetric( + metrics, + `attribution.${retained.bundler}.${candidate.subpath}.rawBytes`, + retained.rawBytes, + "bytes", + "browser-module-attribution", + `${retained.bundler}:@sheetwrite/core/${candidate.subpath}`, + ); + addMetric( + metrics, + `attribution.${retained.bundler}.${candidate.subpath}.brotliBytes`, + retained.brotliBytes, + "bytes", + "browser-module-attribution", + `${retained.bundler}:@sheetwrite/core/${candidate.subpath}`, + ); + } + } for (const [name, path] of [ ["styles", join(repositoryRoot, "packages/core/styles.css")], ["shell", join(repositoryRoot, "packages/core/shell.css")], @@ -829,6 +1315,7 @@ async function buildSizeReport( .sort((left, right) => left.name.localeCompare(right.name)), closures: closures.sort((left, right) => left.name.localeCompare(right.name)), bundlers, + attributionFindings, reproduction: "bun run size:report", }; } finally { diff --git a/scripts/verify-packed-consumer.ts b/scripts/verify-packed-consumer.ts index b7df6b25..120e6f50 100644 --- a/scripts/verify-packed-consumer.ts +++ b/scripts/verify-packed-consumer.ts @@ -4,7 +4,11 @@ import { tmpdir } from "node:os"; import { basename, dirname, join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; import { chromium } from "@playwright/test"; -import { readReleaseManifestDigest, verifyReleaseArtifacts } from "./release-artifacts.js"; +import { + assertPublishedFilePolicy, + readReleaseManifestDigest, + verifyReleaseArtifacts, +} from "./release-artifacts.js"; import { bindCanonicalTarballIntegrities } from "./release-lock-integrity.mjs"; interface PackageManifest { @@ -62,21 +66,13 @@ const packageSpecs: PackageSpec[] = [ "dist/index.d.ts", "dist/index.js", "dist/types/cell.d.ts", - "dist/types/cell.d.ts.map", "dist/types/coordinates.d.ts", - "dist/types/coordinates.d.ts.map", "dist/types/data.d.ts", - "dist/types/data.d.ts.map", "dist/types/document.d.ts", - "dist/types/document.d.ts.map", "dist/types/grid.d.ts", - "dist/types/grid.d.ts.map", "dist/types/render.d.ts", - "dist/types/render.d.ts.map", "dist/types/store.d.ts", - "dist/types/store.d.ts.map", "dist/types/transaction.d.ts", - "dist/types/transaction.d.ts.map", "dist/worker.d.ts", "dist/worker.js", "dist/shell.d.ts", @@ -213,6 +209,31 @@ function rewriteWorkspaceRanges( ); } +async function copyManifestEntry( + sourceRoot: string, + packageRoot: string, + entry: string, +): Promise { + if (!/[*?[\]{}]/u.test(entry)) { + await cp(join(sourceRoot, entry), join(packageRoot, entry), { recursive: true }); + return; + } + const matches = Array.from( + new Bun.Glob(entry).scanSync({ + cwd: sourceRoot, + dot: true, + onlyFiles: true, + followSymlinks: false, + }), + ); + if (matches.length === 0) throw new Error(`Package file pattern matched nothing: ${entry}`); + for (const path of matches) { + const target = join(packageRoot, path); + await mkdir(dirname(target), { recursive: true }); + await cp(join(sourceRoot, path), target); + } +} + async function stagePackage( spec: PackageSpec, stageRoot: string, @@ -224,7 +245,7 @@ async function stagePackage( await mkdir(packageRoot, { recursive: true }); for (const path of [...(manifest.files ?? []), "LICENSE", "README.md"]) { - await cp(join(sourceRoot, path), join(packageRoot, path), { recursive: true }); + await copyManifestEntry(sourceRoot, packageRoot, path); } const stagedManifest: PackageManifest = { @@ -262,6 +283,7 @@ async function assertTarball( extractRoot: string, ): Promise { const packedFiles = new Set(result.files.map((file) => file.path)); + assertPublishedFilePolicy(manifest.name, [...packedFiles]); for (const requiredFile of spec.requiredFiles) { if (!packedFiles.has(requiredFile)) { throw new Error(`${manifest.name} tarball is missing ${requiredFile}`); diff --git a/scripts/wasm-profile-matrix.ts b/scripts/wasm-profile-matrix.ts new file mode 100644 index 00000000..e7c422a4 --- /dev/null +++ b/scripts/wasm-profile-matrix.ts @@ -0,0 +1,428 @@ +import { createHash } from "node:crypto"; +import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { hostname, tmpdir } from "node:os"; +import { basename, dirname, join, resolve } from "node:path"; +import { brotliCompressSync, gzipSync, constants as zlibConstants } from "node:zlib"; +import { + assertCleanMatrixCapture, + decideWasmProfile, + type MatrixGateEvidence, + parseWasmProfileMatrix, + type StoreOnlyProbe, + WASM_OPT_PROFILES, + type WasmOptProfile, +} from "../bench/src/wasm-profile-matrix.js"; + +const ROOT = resolve(import.meta.dir, ".."); +const WASM_CRATE = resolve(ROOT, "packages/wasm"); +const STORE_ONLY_COMMAND = [ + "cargo", + "check", + "--target", + "wasm32-unknown-unknown", + "--no-default-features", + "--message-format=json", +] as const; +const STORE_ONLY_SENTINEL = "SHEETWRITE_STORE_ONLY_BLOCKED"; + +interface CommandResult { + readonly exitCode: number; + readonly stdout: Uint8Array; + readonly stderr: Uint8Array; +} + +interface BuildVariant { + readonly wasmOpt: WasmOptProfile; + readonly inputWasmSha256: string; + readonly artifactPath: string; + readonly artifact: { + readonly rawBytes: number; + readonly gzipBytes: number; + readonly brotliBytes: number; + readonly sha256: string; + }; +} + +interface BuildCapture { + readonly schemaVersion: number; + readonly protocol: string; + readonly provenance: { readonly commit: string; readonly dirty: false }; + readonly rustProfile: unknown; + readonly postLink: unknown; + readonly variants: readonly BuildVariant[]; +} + +interface GateCapture extends MatrixGateEvidence { + readonly wasmOpt: WasmOptProfile; +} + +interface GateCaptureManifest { + readonly schemaVersion: number; + readonly protocol: string; + readonly gate: "formula" | "render"; + readonly captures: readonly GateCapture[]; +} + +function gateEvidence(capture: GateCapture): MatrixGateEvidence { + return { + status: capture.status, + commands: capture.commands, + exitCode: capture.exitCode, + artifactPath: capture.artifactPath, + evidenceSha256: capture.evidenceSha256, + wasmSha256: capture.wasmSha256, + commit: capture.commit, + runnerFingerprint: capture.runnerFingerprint, + }; +} + +function sha256(value: Uint8Array | string): string { + return createHash("sha256").update(value).digest("hex"); +} + +function run(command: readonly string[], cwd = ROOT): CommandResult { + const result = Bun.spawnSync([...command], { cwd, stdout: "pipe", stderr: "pipe" }); + return { + exitCode: result.exitCode, + stdout: result.stdout, + stderr: result.stderr, + }; +} + +function successfulOutput(command: readonly string[], cwd = ROOT): string { + const result = run(command, cwd); + if (result.exitCode !== 0) { + throw new Error( + `${command.join(" ")} failed: ${Buffer.from(result.stderr).toString("utf8").trim()}`, + ); + } + return Buffer.from(result.stdout).toString("utf8").trim(); +} + +function argument(args: readonly string[], name: string): string { + const index = args.indexOf(name); + const value = index < 0 ? undefined : args[index + 1]; + if (!value || value.startsWith("--")) throw new Error(`${name} is required`); + return value; +} + +function optionalArgument(args: readonly string[], name: string): string | undefined { + const index = args.indexOf(name); + const value = index < 0 ? undefined : args[index + 1]; + if (index >= 0 && (!value || value.startsWith("--"))) throw new Error(`${name} requires a value`); + return value; +} + +function writeJson(path: string, value: unknown): void { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`); +} + +function assertControlledCargoProfile(): void { + const cargo = readFileSync(resolve(WASM_CRATE, "Cargo.toml"), "utf8"); + const release = cargo.match(/\[profile\.release\]([\s\S]*?)(?=\n\[|$)/u)?.[1] ?? ""; + if ( + !/^opt-level\s*=\s*3\s*$/mu.test(release) || + !/^lto\s*=\s*"fat"\s*$/mu.test(release) || + !/^codegen-units\s*=\s*1\s*$/mu.test(release) + ) { + throw new Error("Cargo release profile must remain opt-level=3, lto=fat, codegen-units=1"); + } +} + +function assertCleanTree(): string { + const commit = successfulOutput(["git", "rev-parse", "HEAD"]); + if (!/^[0-9a-f]{40}$/u.test(commit)) throw new Error("git returned a non-full commit hash"); + assertCleanMatrixCapture( + successfulOutput(["git", "status", "--porcelain", "--untracked-files=all"]), + ); + return commit; +} + +function compressedSizes(data: Uint8Array): { + rawBytes: number; + gzipBytes: number; + brotliBytes: number; +} { + return { + rawBytes: data.byteLength, + gzipBytes: gzipSync(data, { level: 9 }).byteLength, + brotliBytes: brotliCompressSync(data, { + params: { + [zlibConstants.BROTLI_PARAM_MODE]: zlibConstants.BROTLI_MODE_GENERIC, + [zlibConstants.BROTLI_PARAM_QUALITY]: 11, + [zlibConstants.BROTLI_PARAM_SIZE_HINT]: data.byteLength, + }, + }).byteLength, + }; +} + +function postLink( + wasmOptPath: string, + inputPath: string, + outputDirectory: string, + outputRoot: string, +): BuildVariant[] { + const input = readFileSync(inputPath); + const inputWasmSha256 = sha256(input); + const variants: BuildVariant[] = []; + for (const wasmOpt of WASM_OPT_PROFILES) { + const label = wasmOpt.slice(1).toLowerCase(); + const temporaryOutput = resolve(outputDirectory, `sheetwrite-${label}.wasm`); + const result = run([wasmOptPath, inputPath, wasmOpt, "-o", temporaryOutput]); + if (result.exitCode !== 0) { + throw new Error(`${wasmOpt} failed: ${Buffer.from(result.stderr).toString("utf8").trim()}`); + } + const artifact = readFileSync(temporaryOutput); + const artifactPath = resolve(outputRoot, "artifacts", `sheetwrite-${label}.wasm`); + mkdirSync(dirname(artifactPath), { recursive: true }); + copyFileSync(temporaryOutput, artifactPath); + variants.push({ + wasmOpt, + inputWasmSha256, + artifactPath, + artifact: { + ...compressedSizes(artifact), + sha256: sha256(artifact), + }, + }); + } + return variants; +} + +function captureBuild(args: readonly string[]): void { + assertControlledCargoProfile(); + const commit = assertCleanTree(); + const wasmOptPath = resolve(argument(args, "--wasm-opt")); + const output = resolve(argument(args, "--output")); + const outputRoot = dirname(output); + const temporary = mkdtempSync(join(tmpdir(), "sheetwrite-wasm-profile-")); + try { + const buildDirectory = resolve(temporary, "unoptimized"); + const build = run( + [ + "wasm-pack", + "build", + "--target", + "web", + "--release", + "--no-opt", + "--out-dir", + buildDirectory, + ], + WASM_CRATE, + ); + if (build.exitCode !== 0) { + throw new Error( + `wasm-pack build failed: ${Buffer.from(build.stderr).toString("utf8").trim()}`, + ); + } + const inputPath = resolve(buildDirectory, "sheetwrite_wasm_bg.wasm"); + const variants = postLink(wasmOptPath, inputPath, temporary, outputRoot); + const wasmOptBytes = readFileSync(wasmOptPath); + const capture = { + schemaVersion: 1, + protocol: "wasm-post-link-build-capture-v1", + provenance: { + commit, + dirty: false, + timestamp: new Date().toISOString(), + host: hostname(), + rustc: successfulOutput(["rustc", "--version"]), + cargo: successfulOutput(["cargo", "--version"]), + wasmPack: successfulOutput(["wasm-pack", "--version"]), + wasmOptVersion: successfulOutput([wasmOptPath, "--version"]), + wasmOptSha256: sha256(wasmOptBytes), + bun: Bun.version, + }, + rustProfile: { optLevel: 3, lto: "fat", codegenUnits: 1 }, + postLink: { + inputWasmSha256: variants[0]!.inputWasmSha256, + compression: { gzipLevel: 9, brotliQuality: 11, brotliMode: "generic" }, + }, + variants, + completion: { + status: "gates-required", + required: ["formulaGate", "renderGate", "storeOnlyProbe"], + }, + }; + writeJson(output, capture); + process.stdout.write(`${output}\n`); + } finally { + rmSync(temporary, { force: true, recursive: true }); + } +} + +function captureStoreOnlyProbe(args: readonly string[]): void { + const commit = assertCleanTree(); + const output = resolve(argument(args, "--output")); + const result = run(STORE_ONLY_COMMAND, WASM_CRATE); + const stdoutText = Buffer.from(result.stdout).toString("utf8"); + const stderrText = Buffer.from(result.stderr).toString("utf8"); + const recognizedBlock = + result.exitCode !== 0 && `${stdoutText}\n${stderrText}`.includes(STORE_ONLY_SENTINEL); + const outcome: StoreOnlyProbe["outcome"] = + result.exitCode === 0 ? "compiled" : recognizedBlock ? "blocked" : "failed"; + const blockers: StoreOnlyProbe["blockers"] = recognizedBlock + ? [ + { + id: "formula-entry-ast", + kind: "dependency-cycle", + owner: "types::FormulaEntry::ast", + dependency: "calc::Ast", + reason: "stored formula syntax is owned by the data-store type graph", + }, + { + id: "dependency-index", + kind: "dependency-cycle", + owner: "store::CellStore::dep_index", + dependency: "eval::DepIndex", + reason: "recalculation dependency state is owned directly by CellStore", + }, + ] + : []; + const probe: StoreOnlyProbe = { + provenance: { + commit, + dirty: false, + timestamp: new Date().toISOString(), + }, + command: STORE_ONLY_COMMAND, + outcome, + exitCode: result.exitCode, + stdoutSha256: sha256(result.stdout), + stderrSha256: sha256(result.stderr), + blockers, + }; + writeJson(output, probe); + process.stdout.write(`${output}: ${outcome}\n`); + if (outcome === "failed") process.exitCode = 1; +} + +function readGateManifest(path: string, expectedGate: "formula" | "render"): GateCaptureManifest { + const manifest = JSON.parse(readFileSync(path, "utf8")) as GateCaptureManifest; + if ( + manifest.schemaVersion !== 1 || + manifest.protocol !== "wasm-post-link-gate-capture-v1" || + manifest.gate !== expectedGate || + !Array.isArray(manifest.captures) + ) { + throw new Error(`${expectedGate} gate manifest has the wrong protocol or gate kind`); + } + const modes = manifest.captures.map(({ wasmOpt }) => wasmOpt); + if ( + modes.length !== WASM_OPT_PROFILES.length || + new Set(modes).size !== WASM_OPT_PROFILES.length || + !WASM_OPT_PROFILES.every((profile) => modes.includes(profile)) + ) { + throw new Error(`${expectedGate} gate manifest must capture -O3, -Os, and -Oz exactly once`); + } + return manifest; +} + +function assertCaptureFiles(build: BuildCapture, manifests: readonly GateCaptureManifest[]): void { + for (const variant of build.variants) { + const actual = sha256(readFileSync(resolve(ROOT, variant.artifactPath))); + if (actual !== variant.artifact.sha256) { + throw new Error(`${variant.wasmOpt} build artifact hash no longer matches its capture`); + } + } + for (const manifest of manifests) { + for (const capture of manifest.captures) { + const actual = sha256(readFileSync(resolve(ROOT, capture.artifactPath))); + if (actual !== capture.evidenceSha256) { + throw new Error( + `${manifest.gate}/${capture.wasmOpt} evidence hash does not match its file`, + ); + } + } + } +} + +function assemble(args: readonly string[]): void { + const build = JSON.parse( + readFileSync(resolve(argument(args, "--build")), "utf8"), + ) as BuildCapture; + if ( + build.schemaVersion !== 1 || + build.protocol !== "wasm-post-link-build-capture-v1" || + !Array.isArray(build.variants) + ) { + throw new Error("build input is not a WASM post-link build capture"); + } + const formula = readGateManifest(resolve(argument(args, "--formula-gates")), "formula"); + const render = readGateManifest(resolve(argument(args, "--render-gates")), "render"); + const storeOnlyProbe = JSON.parse( + readFileSync(resolve(argument(args, "--store-probe")), "utf8"), + ) as StoreOnlyProbe; + if ( + storeOnlyProbe.provenance.dirty !== false || + storeOnlyProbe.provenance.commit !== build.provenance.commit + ) { + throw new Error("store-only probe must come from the clean build capture commit"); + } + assertCaptureFiles(build, [formula, render]); + const formulaByProfile = new Map(formula.captures.map((capture) => [capture.wasmOpt, capture])); + const renderByProfile = new Map(render.captures.map((capture) => [capture.wasmOpt, capture])); + const matrix = { + schemaVersion: 1, + protocol: "wasm-post-link-profile-matrix-v1", + provenance: build.provenance, + rustProfile: build.rustProfile, + postLink: build.postLink, + decisionPolicy: { + sizeMetric: "brotliBytes", + requireFormulaGate: true, + requireRenderGate: true, + }, + captureOrder: { + formula: formula.captures.map(({ wasmOpt }) => wasmOpt), + render: render.captures.map(({ wasmOpt }) => wasmOpt), + }, + variants: build.variants.map((variant) => { + const formulaCapture = formulaByProfile.get(variant.wasmOpt); + const renderCapture = renderByProfile.get(variant.wasmOpt); + if (!formulaCapture || !renderCapture) { + throw new Error(`${variant.wasmOpt} is missing gate evidence`); + } + const formulaGate = gateEvidence(formulaCapture); + const renderGate = gateEvidence(renderCapture); + return { + wasmOpt: variant.wasmOpt, + inputWasmSha256: variant.inputWasmSha256, + artifact: variant.artifact, + formulaGate, + renderGate, + }; + }), + storeOnlyProbe, + }; + const parsed = parseWasmProfileMatrix(matrix); + const output = resolve(argument(args, "--output")); + writeJson(output, parsed); + const decision = decideWasmProfile(parsed); + process.stdout.write(`${output}\n${JSON.stringify(decision, null, 2)}\n`); + if (decision.status === "blocked") process.exitCode = 1; +} + +function decide(args: readonly string[]): void { + const input = resolve(argument(args, "--input")); + const raw = JSON.parse(readFileSync(input, "utf8")) as unknown; + const evidence = parseWasmProfileMatrix(raw); + const decision = decideWasmProfile(evidence); + const output = optionalArgument(args, "--output"); + if (output) writeJson(resolve(output), decision); + process.stdout.write(`${JSON.stringify(decision, null, 2)}\n`); + if (decision.status === "blocked") process.exitCode = 1; +} + +const [command, ...args] = process.argv.slice(2); +if (command === "build") captureBuild(args); +else if (command === "probe-store") captureStoreOnlyProbe(args); +else if (command === "assemble") assemble(args); +else if (command === "decide") decide(args); +else { + throw new Error( + `usage:\n ${basename(process.argv[1] ?? "wasm-profile-matrix.ts")} build --wasm-opt PATH --output PATH\n ${basename(process.argv[1] ?? "wasm-profile-matrix.ts")} probe-store --output PATH\n ${basename(process.argv[1] ?? "wasm-profile-matrix.ts")} assemble --build PATH --formula-gates PATH --render-gates PATH --store-probe PATH --output PATH\n ${basename(process.argv[1] ?? "wasm-profile-matrix.ts")} decide --input PATH [--output PATH]`, + ); +} diff --git a/scripts/workflow-contract.test.ts b/scripts/workflow-contract.test.ts index 221277e4..7407bac3 100644 --- a/scripts/workflow-contract.test.ts +++ b/scripts/workflow-contract.test.ts @@ -192,10 +192,18 @@ describe("CI and release workflow contracts", () => { const projectByName = Object.fromEntries( (playwrightConfig.projects ?? []).map((project) => [project.name, project]), ); - expect(Object.keys(projectByName)).toEqual(["chromium", "firefox", "webkit"]); + expect(Object.keys(projectByName)).toEqual([ + "chromium", + "firefox", + "webkit", + "chromium-engine-dpr2", + "webkit-engine-dpr2", + ]); expect(projectByName.chromium?.grep).toBeUndefined(); expect(String(projectByName.firefox?.grep)).toBe("/@portability/"); expect(String(projectByName.webkit?.grep)).toBe("/@portability/"); + expect(String(projectByName["chromium-engine-dpr2"]?.grep)).toBe("/@dpr2-render/"); + expect(String(projectByName["webkit-engine-dpr2"]?.grep)).toBe("/@dpr2-render/"); }); it("applies the canonical JavaScript toolchain to the trusted publishing job", () => { diff --git a/test/browser/playwright.config.ts b/test/browser/playwright.config.ts index ac7c316d..d2cb1a4c 100644 --- a/test/browser/playwright.config.ts +++ b/test/browser/playwright.config.ts @@ -30,6 +30,7 @@ export default defineConfig({ projects: [ { name: "chromium", + grepInvert: /@dpr2-render/, use: { browserName: "chromium" }, }, { @@ -42,6 +43,37 @@ export default defineConfig({ grep: /@portability/, use: { browserName: "webkit" }, }, + // These projects exercise Playwright browser engines at a real DPR 2. + // Their names deliberately make no OS-browser claim: native macOS Safari + // hardware verification remains an external release check. + { + name: "chromium-engine-dpr2", + grep: /@dpr2-render/, + metadata: { + automationEngine: "Playwright Chromium", + hostPlatform: `${process.platform}-${process.arch}`, + nativeMacOSSafariHardwareVerification: "external", + }, + use: { + browserName: "chromium", + deviceScaleFactor: 2, + viewport: { width: 1_568, height: 900 }, + }, + }, + { + name: "webkit-engine-dpr2", + grep: /@dpr2-render/, + metadata: { + automationEngine: "Playwright WebKit (not macOS Safari)", + hostPlatform: `${process.platform}-${process.arch}`, + nativeMacOSSafariHardwareVerification: "external", + }, + use: { + browserName: "webkit", + deviceScaleFactor: 2, + viewport: { width: 1_568, height: 900 }, + }, + }, ], webServer: { command: `PORT=${SITE_PORT} bun scripts/serve-docs.ts`, diff --git a/test/browser/vanilla-workbench.spec.ts b/test/browser/vanilla-workbench.spec.ts index fe8c8dd6..1b32c16d 100644 --- a/test/browser/vanilla-workbench.spec.ts +++ b/test/browser/vanilla-workbench.spec.ts @@ -70,6 +70,50 @@ async function canvasBodyPainted(page: Page): Promise { return hasOpaqueForeground(rgba); } +interface DprCanvasEvidence { + backingHeight: number; + backingWidth: number; + cssHeight: number; + cssWidth: number; + devicePixelRatio: number; + sample: number[]; + sampleHeight: number; + sampleWidth: number; +} + +async function readDprCanvasEvidence(page: Page): Promise { + return page.locator(CANVAS).evaluate((element) => { + const canvas = element as HTMLCanvasElement; + const context = canvas.getContext("2d"); + const bounds = canvas.getBoundingClientRect(); + if (!context) throw new Error("Main-thread canvas 2D context is unavailable"); + if (bounds.width <= 0 || bounds.height <= 0) { + throw new Error("Main-thread canvas has no CSS dimensions"); + } + + const scaleX = canvas.width / bounds.width; + const scaleY = canvas.height / bounds.height; + const left = Math.ceil(64 * scaleX); + const top = Math.ceil(40 * scaleY); + const sampleWidth = Math.min(Math.ceil(256 * scaleX), canvas.width - left); + const sampleHeight = Math.min(Math.ceil(160 * scaleY), canvas.height - top); + if (sampleWidth <= 0 || sampleHeight <= 0) { + throw new Error("Main-thread canvas body sample is empty"); + } + + return { + backingHeight: canvas.height, + backingWidth: canvas.width, + cssHeight: bounds.height, + cssWidth: bounds.width, + devicePixelRatio: window.devicePixelRatio, + sample: Array.from(context.getImageData(left, top, sampleWidth, sampleHeight).data), + sampleHeight, + sampleWidth, + }; + }); +} + async function waitForLive(page: Page): Promise { await expect(page.getByTestId("lifecycle")).toHaveAttribute("data-phase", "live", { timeout: 20_000, @@ -171,6 +215,94 @@ test("boots product-first, paints, and exposes the ownership instruments", async expect(errors.console).toEqual([]); }); +test( + "main-thread production canvas paints cells in a DPR 2 backing store", + { + tag: "@dpr2-render", + }, + async ({ browser, browserName, page }, testInfo) => { + const errors = collectErrors(page); + await page.goto(`${VANILLA_URL}?renderer=canvas`); + await waitForLive(page); + await expect(page.getByTestId("renderer")).toContainText( + "Requested: Main thread · Active: Main thread", + ); + await expect + .poll(async () => hasOpaqueForeground((await readDprCanvasEvidence(page)).sample), { + timeout: 15_000, + message: "DPR 2 main-thread canvas body never painted cell foreground", + }) + .toBe(true); + + const evidence = await readDprCanvasEvidence(page); + const visibleCells = await gridCellTexts(page); + const runtimeIdentity = await page.evaluate(() => ({ + navigatorPlatform: navigator.platform, + userAgent: navigator.userAgent, + vendor: navigator.vendor, + })); + const opaqueColors = new Set(); + let opaquePixels = 0; + for (let offset = 0; offset + 3 < evidence.sample.length; offset += 4) { + if (evidence.sample[offset + 3] !== 255) continue; + opaquePixels += 1; + opaqueColors.add( + `${evidence.sample[offset]},${evidence.sample[offset + 1]},${evidence.sample[offset + 2]}`, + ); + } + const expectedBackingWidth = Math.max(1, Math.round(evidence.cssWidth * 2)); + const expectedBackingHeight = Math.max(1, Math.round(evidence.cssHeight * 2)); + const renderEvidence = { + automation: { + browserEngine: browserName, + browserVersion: browser.version(), + hostPlatform: `${process.platform}-${process.arch}`, + project: testInfo.project.name, + projectMetadata: testInfo.project.metadata, + ...runtimeIdentity, + }, + canvas: { + backingHeight: evidence.backingHeight, + backingWidth: evidence.backingWidth, + cssHeight: evidence.cssHeight, + cssWidth: evidence.cssWidth, + devicePixelRatio: evidence.devicePixelRatio, + expectedBackingHeight, + expectedBackingWidth, + }, + cells: { + expectedVisibleValue: FIRST_ACCOUNT, + foregroundPainted: hasOpaqueForeground(evidence.sample), + opaqueColorCount: opaqueColors.size, + opaquePixels, + sampleHeight: evidence.sampleHeight, + sampleWidth: evidence.sampleWidth, + }, + }; + await testInfo.attach("dpr2-main-thread-render-evidence", { + body: Buffer.from(JSON.stringify(renderEvidence, null, 2)), + contentType: "application/json", + }); + + expect(["chromium", "webkit"]).toContain(browserName); + expect(testInfo.project.name).toBe(`${browserName}-engine-dpr2`); + expect(testInfo.project.metadata).toMatchObject({ + automationEngine: + browserName === "chromium" ? "Playwright Chromium" : "Playwright WebKit (not macOS Safari)", + hostPlatform: `${process.platform}-${process.arch}`, + nativeMacOSSafariHardwareVerification: "external", + }); + expect(evidence.devicePixelRatio).toBe(2); + expect(evidence.backingWidth).toBe(expectedBackingWidth); + expect(evidence.backingHeight).toBe(expectedBackingHeight); + expect(hasOpaqueForeground(evidence.sample)).toBe(true); + expect(visibleCells).toContain(FIRST_ACCOUNT); + expect(errors.page).toEqual([]); + expect(errors.worker).toEqual([]); + expect(errors.console).toEqual([]); + }, +); + test("scenario tabs support keyboard focus and drive real construction options", async ({ page, }) => { diff --git a/test/bundler-fixtures/first-paint/candidate-package.mjs b/test/bundler-fixtures/first-paint/candidate-package.mjs new file mode 100644 index 00000000..0590f411 --- /dev/null +++ b/test/bundler-fixtures/first-paint/candidate-package.mjs @@ -0,0 +1,72 @@ +import { rename, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; + +export const deferredModule = "@sheetwrite/core/dist/clipboard-controller.js"; +const originalName = "clipboard-controller.js"; +const deferredName = "clipboard-controller.first-interaction.js"; + +const proxySource = `const loadEvent = "sheetwrite:first-interaction-module"; + +function publish(detail) { + globalThis.dispatchEvent?.(new CustomEvent(loadEvent, { detail })); +} + +export const SHEETWRITE_CLIPBOARD_MIME = "application/x-sheetwrite+json"; + +export class ClipboardController { + #deps; + #controller; + + constructor(deps) { + this.#deps = deps; + } + + async #load() { + if (this.#controller === undefined) { + publish({ module: ${JSON.stringify(deferredModule)}, state: "loading" }); + this.#controller = import("./${deferredName}") + .then(({ ClipboardController: DeferredClipboardController }) => { + publish({ module: ${JSON.stringify(deferredModule)}, state: "loaded" }); + return new DeferredClipboardController(this.#deps); + }) + .catch((error) => { + publish({ + module: ${JSON.stringify(deferredModule)}, + state: "error", + message: error instanceof Error ? error.message : String(error), + }); + return null; + }); + } + return this.#controller; + } + + async copy() { + const controller = await this.#load(); + return controller === null ? "unsupported" : controller.copy(); + } + + async cut() { + const controller = await this.#load(); + return controller === null ? "unsupported" : controller.cut(); + } + + async paste() { + const controller = await this.#load(); + return controller === null ? "unsupported" : controller.paste(); + } + + async pasteValues() { + const controller = await this.#load(); + return controller === null ? "unsupported" : controller.pasteValues(); + } +} +`; + +export async function installFixtureOnlyCandidate(corePackageRoot) { + const coreDist = resolve(corePackageRoot, "dist"); + const originalPath = resolve(coreDist, originalName); + const deferredPath = resolve(coreDist, deferredName); + await rename(originalPath, deferredPath); + await writeFile(originalPath, proxySource); +} diff --git a/test/bundler-fixtures/first-paint/next/app/layout.tsx b/test/bundler-fixtures/first-paint/next/app/layout.tsx new file mode 100644 index 00000000..a5ae26e3 --- /dev/null +++ b/test/bundler-fixtures/first-paint/next/app/layout.tsx @@ -0,0 +1,9 @@ +import "@sheetwrite/core/styles.css"; + +export default function Layout({ children }: Readonly<{ children: React.ReactNode }>) { + return ( + + {children} + + ); +} diff --git a/test/bundler-fixtures/first-paint/next/app/page.tsx b/test/bundler-fixtures/first-paint/next/app/page.tsx new file mode 100644 index 00000000..f786b54d --- /dev/null +++ b/test/bundler-fixtures/first-paint/next/app/page.tsx @@ -0,0 +1,76 @@ +"use client"; + +import { + type ColumnarData, + createGrid, + type Grid, + initSheetwrite, + type Workbook, +} from "@sheetwrite/core"; +import { useEffect, useRef } from "react"; + +declare global { + interface Window { + __sheetwriteFirstPaint?: { + grid: Grid; + readyMs: number; + surface: readonly ["createGrid", "initSheetwrite"]; + }; + } +} + +const workbook: Workbook = { + activeSheet: "first-paint", + sheets: [ + { + id: "first-paint", + name: "First paint", + rowCount: 3, + columns: [ + { key: "name", header: "Name", width: 160, type: "text" }, + { key: "amount", header: "Amount", width: 120, type: "number" }, + ], + }, + ], +}; +const data: ColumnarData = { + rowCount: 3, + columns: { + name: ["Alpha", "Beta", "Gamma"], + amount: new Float64Array([1, 2, 3]), + }, +}; + +export default function Page() { + const hostRef = useRef(null); + useEffect(() => { + const host = hostRef.current; + if (host === null) throw new Error("First-paint fixture host is unavailable"); + let cancelled = false; + let grid: Grid | undefined; + void (async () => { + await initSheetwrite(); + if (cancelled) return; + grid = createGrid(host, { + data, + workbook, + config: { contextMenu: false, find: false, toolbar: false }, + }); + await new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())), + ); + if (cancelled) return; + window.__sheetwriteFirstPaint = { + grid, + readyMs: performance.now(), + surface: ["createGrid", "initSheetwrite"], + }; + })(); + return () => { + cancelled = true; + grid?.destroy(); + delete window.__sheetwriteFirstPaint; + }; + }, []); + return
; +} diff --git a/test/bundler-fixtures/first-paint/next/next-env.d.ts b/test/bundler-fixtures/first-paint/next/next-env.d.ts new file mode 100644 index 00000000..830fb594 --- /dev/null +++ b/test/bundler-fixtures/first-paint/next/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/test/bundler-fixtures/first-paint/next/next.config.mjs b/test/bundler-fixtures/first-paint/next/next.config.mjs new file mode 100644 index 00000000..93e75a99 --- /dev/null +++ b/test/bundler-fixtures/first-paint/next/next.config.mjs @@ -0,0 +1,15 @@ +/** @type {import("next").NextConfig} */ +const nextConfig = { + output: "export", + productionBrowserSourceMaps: true, + outputFileTracingRoot: import.meta.dirname, + webpack(config, { dev, isServer }) { + const core = process.env.SHEETWRITE_FIRST_PAINT_CORE; + if (!core) throw new Error("SHEETWRITE_FIRST_PAINT_CORE is required"); + config.resolve.alias["@sheetwrite/core"] = core; + if (!dev && !isServer) config.devtool = "hidden-source-map"; + return config; + }, +}; + +export default nextConfig; diff --git a/test/bundler-fixtures/first-paint/next/node_modules b/test/bundler-fixtures/first-paint/next/node_modules new file mode 120000 index 00000000..0c8c7006 --- /dev/null +++ b/test/bundler-fixtures/first-paint/next/node_modules @@ -0,0 +1 @@ +/home/vvin/learn/sheetwrite/test/bundler-fixtures/next/node_modules \ No newline at end of file diff --git a/test/bundler-fixtures/first-paint/next/package.json b/test/bundler-fixtures/first-paint/next/package.json new file mode 100644 index 00000000..f884151e --- /dev/null +++ b/test/bundler-fixtures/first-paint/next/package.json @@ -0,0 +1,13 @@ +{ + "private": true, + "scripts": { + "build": "next build" + }, + "dependencies": { + "@sheetwrite/core": "0.3.1", + "@sheetwrite/wasm": "0.3.1", + "next": "15.5.9", + "react": "19.1.0", + "react-dom": "19.1.0" + } +} diff --git a/test/bundler-fixtures/first-paint/next/tsconfig.json b/test/bundler-fixtures/first-paint/next/tsconfig.json new file mode 100644 index 00000000..6fc42e00 --- /dev/null +++ b/test/bundler-fixtures/first-paint/next/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": false, + "noEmit": true, + "incremental": true, + "module": "esnext", + "esModuleInterop": true, + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "plugins": [{ "name": "next" }] + }, + "include": ["next-env.d.ts", ".next/types/**/*.ts", "**/*.ts", "**/*.tsx"], + "exclude": ["node_modules"] +} diff --git a/test/bundler-fixtures/first-paint/run.mjs b/test/bundler-fixtures/first-paint/run.mjs new file mode 100644 index 00000000..15e2023f --- /dev/null +++ b/test/bundler-fixtures/first-paint/run.mjs @@ -0,0 +1,385 @@ +import { spawn } from "node:child_process"; +import { createHash } from "node:crypto"; +import { cp, mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import { createServer } from "node:http"; +import { extname, relative, resolve, sep } from "node:path"; +import { brotliCompressSync, constants, gzipSync } from "node:zlib"; +import { chromium } from "@playwright/test"; +import { deferredModule, installFixtureOnlyCandidate } from "./candidate-package.mjs"; + +const fixtureRoot = resolve(import.meta.dirname, ".."); +const repositoryRoot = resolve(fixtureRoot, "../.."); +const outputRoot = resolve(repositoryRoot, "test-results/bundlers/first-paint"); +const viteDependencyRoot = resolve(fixtureRoot, "vite"); +const nextDependencyRoot = resolve(fixtureRoot, "next"); +const viteFixtureRoot = resolve(import.meta.dirname, "vite"); +const nextFixtureRoot = resolve(import.meta.dirname, "next"); +const rounds = 10; + +function run(command, args, cwd, environment = {}) { + return new Promise((resolveRun, reject) => { + const child = spawn(command, args, { + cwd, + env: { ...process.env, ...environment }, + stdio: "inherit", + }); + child.once("error", reject); + child.once("exit", (code, signal) => { + if (code === 0) resolveRun(); + else reject(new Error(`${command} ${args.join(" ")} failed (${code ?? signal})`)); + }); + }); +} + +async function replaceNodeModulesLink(target, source) { + await rm(target, { recursive: true, force: true }); + await symlink(source, target, "dir"); +} + +async function buildVite(variant, corePackageRoot) { + const output = resolve(outputRoot, "vite", variant); + await rm(output, { recursive: true, force: true }); + await run( + resolve(viteDependencyRoot, "node_modules/.bin/vite"), + ["build", "--config", resolve(viteFixtureRoot, "vite.config.ts")], + viteFixtureRoot, + { + SHEETWRITE_FIRST_PAINT_CORE: corePackageRoot, + SHEETWRITE_FIRST_PAINT_OUTPUT: output, + }, + ); +} + +async function buildNext(variant, corePackageRoot) { + await rm(resolve(nextFixtureRoot, ".next"), { recursive: true, force: true }); + await rm(resolve(nextFixtureRoot, "out"), { recursive: true, force: true }); + await run(resolve(nextDependencyRoot, "node_modules/.bin/next"), ["build"], nextFixtureRoot, { + SHEETWRITE_FIRST_PAINT_CORE: corePackageRoot, + }); + const output = resolve(outputRoot, "next", variant); + await rm(output, { recursive: true, force: true }); + await cp(resolve(nextFixtureRoot, "out"), output, { recursive: true }); +} + +async function buildPair(name, dependencyRoot, build) { + const baselineCore = resolve(dependencyRoot, "node_modules/@sheetwrite/core"); + const candidateCore = resolve(outputRoot, ".candidate-core", name.toLowerCase()); + await mkdir(resolve(outputRoot, ".candidate-core"), { recursive: true }); + await rm(candidateCore, { recursive: true, force: true }); + await cp(baselineCore, candidateCore, { recursive: true }); + const candidateScope = resolve(candidateCore, "node_modules/@sheetwrite"); + await mkdir(candidateScope, { recursive: true }); + await symlink( + resolve(dependencyRoot, "node_modules/@sheetwrite/wasm"), + resolve(candidateScope, "wasm"), + "dir", + ); + await installFixtureOnlyCandidate(candidateCore); + await build("baseline", baselineCore); + await build("candidate", candidateCore); + console.log(`${name} first-paint baseline/candidate built`); +} + +async function withServer(root, callback) { + const server = createServer(async (request, response) => { + try { + const url = new URL(request.url ?? "/", "http://127.0.0.1"); + const pathname = decodeURIComponent(url.pathname); + const requested = pathname.endsWith("/") ? `${pathname}index.html` : pathname; + const file = resolve(root, `.${requested}`); + if (file !== root && !file.startsWith(`${root}${sep}`)) { + response.writeHead(403).end(); + return; + } + const bytes = await readFile(file); + const contentType = + extname(file) === ".html" + ? "text/html; charset=utf-8" + : extname(file) === ".js" + ? "text/javascript; charset=utf-8" + : extname(file) === ".css" + ? "text/css; charset=utf-8" + : extname(file) === ".wasm" + ? "application/wasm" + : "application/octet-stream"; + response.writeHead(200, { "content-type": contentType }).end(bytes); + } catch { + response.writeHead(404).end(); + } + }); + const listening = Promise.withResolvers(); + server.once("error", listening.reject); + server.listen(0, "127.0.0.1", listening.resolve); + await listening.promise; + const address = server.address(); + if (address === null || typeof address === "string") throw new Error("Static server has no port"); + try { + return await callback(`http://127.0.0.1:${address.port}`); + } finally { + const closed = Promise.withResolvers(); + server.close(closed.resolve); + await closed.promise; + } +} + +function summarizeBytes(chunks) { + const totals = { rawBytes: 0, gzipBytes: 0, brotliBytes: 0 }; + for (const chunk of chunks) { + totals.rawBytes += chunk.byteLength; + totals.gzipBytes += gzipSync(chunk, { level: 9, mtime: 0 }).byteLength; + totals.brotliBytes += brotliCompressSync(chunk, { + params: { [constants.BROTLI_PARAM_QUALITY]: 11 }, + }).byteLength; + } + return totals; +} + +async function capturePage(browser, root, path = "/") { + return withServer(root, async (origin) => { + const page = await browser.newPage({ + deviceScaleFactor: 1, + viewport: { height: 500, width: 800 }, + }); + const errors = []; + page.on("pageerror", (error) => errors.push(error.message)); + await page.goto(`${origin}${path}`, { waitUntil: "domcontentloaded" }); + await page.waitForFunction(() => window.__sheetwriteFirstPaint !== undefined, undefined, { + timeout: 20_000, + }); + const state = await page.evaluate(() => ({ + readyMs: window.__sheetwriteFirstPaint.readyMs, + surface: window.__sheetwriteFirstPaint.surface, + scripts: performance + .getEntriesByType("resource") + .map((entry) => entry.name) + .filter((name) => new URL(name).pathname.endsWith(".js")), + })); + if (JSON.stringify(state.surface) !== JSON.stringify(["createGrid", "initSheetwrite"])) { + throw new Error("Fixture did not preserve the createGrid/initSheetwrite surface"); + } + const host = page.locator("[data-sheetwrite-first-paint-host]"); + const screenshot = await host.screenshot({ animations: "disabled" }); + const checksum = createHash("sha256").update(screenshot).digest("hex"); + if (errors.length > 0) throw new Error(`First-paint fixture failed:\n${errors.join("\n")}`); + await page.close(); + const scripts = await Promise.all( + state.scripts.map((url) => readFile(resolve(root, `.${new URL(url).pathname}`))), + ); + return { checksum, initialJavaScript: summarizeBytes(scripts), readyMs: state.readyMs }; + }); +} + +async function interactionEvidence(browser, root, path = "/") { + return withServer(root, async (origin) => { + const page = await browser.newPage(); + await page.goto(`${origin}${path}`, { waitUntil: "domcontentloaded" }); + await page.waitForFunction(() => window.__sheetwriteFirstPaint !== undefined); + const before = await page.evaluate(() => + performance.getEntriesByType("resource").map((entry) => entry.name), + ); + const success = await page.evaluate(async () => { + const events = []; + globalThis.addEventListener("sheetwrite:first-interaction-module", (event) => + events.push(event.detail), + ); + let rejected = false; + let outcome; + try { + outcome = await window.__sheetwriteFirstPaint.grid.actions.copy(); + } catch { + rejected = true; + } + return { events, outcome, rejected }; + }); + const after = await page.evaluate(() => + performance.getEntriesByType("resource").map((entry) => entry.name), + ); + const deferredUrl = after.find( + (url) => !before.includes(url) && new URL(url).pathname.endsWith(".js"), + ); + if ( + deferredUrl === undefined || + success.outcome !== "empty" || + success.rejected || + !success.events.some((event) => event.module === deferredModule && event.state === "loaded") + ) { + throw new Error("Candidate did not load the named clipboard module on first interaction"); + } + await page.close(); + + const errorPage = await browser.newPage(); + await errorPage.route(deferredUrl, (route) => route.abort("failed")); + await errorPage.goto(`${origin}${path}`, { waitUntil: "domcontentloaded" }); + await errorPage.waitForFunction(() => window.__sheetwriteFirstPaint !== undefined); + const failure = await errorPage.evaluate(async () => { + const events = []; + globalThis.addEventListener("sheetwrite:first-interaction-module", (event) => + events.push(event.detail), + ); + let rejected = false; + let outcome; + try { + outcome = await window.__sheetwriteFirstPaint.grid.actions.copy(); + } catch { + rejected = true; + } + return { events, outcome, rejected }; + }); + await errorPage.close(); + if ( + failure.outcome !== "unsupported" || + failure.rejected || + !failure.events.some((event) => event.module === deferredModule && event.state === "error") + ) { + throw new Error("Candidate did not fail closed when its interaction chunk was unavailable"); + } + return { + deferredChunk: new URL(deferredUrl).pathname, + interaction: { + module: deferredModule, + loaded: true, + successOutcome: success.outcome, + errorOutcome: failure.outcome, + rejected: failure.rejected, + }, + }; + }); +} + +function timing(samplesMs) { + const sorted = [...samplesMs].sort((left, right) => left - right); + const middle = Math.floor(sorted.length / 2); + return { + samplesMs, + medianMs: sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle], + p95Ms: sorted[Math.ceil(sorted.length * 0.95) - 1], + }; +} + +async function captureFixture(browser, bundler, version, provenance, path) { + const roots = { + baseline: resolve(outputRoot, bundler, "baseline"), + candidate: resolve(outputRoot, bundler, "candidate"), + }; + const observations = { baseline: [], candidate: [] }; + for (let round = 0; round < rounds; round++) { + const order = round % 2 === 0 ? ["baseline", "candidate"] : ["candidate", "baseline"]; + for (const variant of order) { + observations[variant].push(await capturePage(browser, roots[variant], path)); + } + } + const baseline = observations.baseline[0]; + const candidate = observations.candidate[0]; + if (observations.baseline.some((entry) => entry.checksum !== baseline.checksum)) { + throw new Error(`${bundler} baseline first-paint checksum was unstable`); + } + if (observations.candidate.some((entry) => entry.checksum !== candidate.checksum)) { + throw new Error(`${bundler} candidate first-paint checksum was unstable`); + } + const interaction = await interactionEvidence(browser, roots.candidate, path); + const delta = { + rawBytes: baseline.initialJavaScript.rawBytes - candidate.initialJavaScript.rawBytes, + gzipBytes: baseline.initialJavaScript.gzipBytes - candidate.initialJavaScript.gzipBytes, + brotliBytes: baseline.initialJavaScript.brotliBytes - candidate.initialJavaScript.brotliBytes, + }; + return { + bundler, + version, + provenance, + eagerImports: ["@sheetwrite/core#createGrid", "@sheetwrite/core#initSheetwrite"], + baseline: { + initialJavaScript: baseline.initialJavaScript, + timing: timing(observations.baseline.map((entry) => entry.readyMs)), + checksum: baseline.checksum, + }, + candidate: { + initialJavaScript: candidate.initialJavaScript, + timing: timing(observations.candidate.map((entry) => entry.readyMs)), + checksum: candidate.checksum, + ...interaction, + }, + delta: { + ...delta, + brotliPercent: (delta.brotliBytes / baseline.initialJavaScript.brotliBytes) * 100, + }, + }; +} + +for (const [fixture, dependency] of [ + [viteFixtureRoot, viteDependencyRoot], + [nextFixtureRoot, nextDependencyRoot], +]) { + await replaceNodeModulesLink( + resolve(fixture, "node_modules"), + resolve(dependency, "node_modules"), + ); +} +await rm(outputRoot, { recursive: true, force: true }); +await mkdir(outputRoot, { recursive: true }); +await buildPair("Vite", viteDependencyRoot, buildVite); +await buildPair("Next.js", nextDependencyRoot, buildNext); + +const vitePackage = JSON.parse(await readFile(resolve(viteDependencyRoot, "package.json"), "utf8")); +const nextPackage = JSON.parse(await readFile(resolve(nextDependencyRoot, "package.json"), "utf8")); +const browser = await chromium.launch({ headless: true }); +let fixtures; +try { + fixtures = [ + await captureFixture( + browser, + "next", + nextPackage.dependencies.next, + { + buildMode: "production", + minified: true, + minifier: { name: "next-swc", version: nextPackage.dependencies.next }, + externals: [], + target: "browser", + sourceMaps: "hidden-external", + attributionMethod: "source-map-generated-spans-with-explicit-opaque-assets-v2", + }, + "/", + ), + await captureFixture( + browser, + "vite", + vitePackage.dependencies.vite, + { + buildMode: "production", + minified: true, + minifier: { name: "esbuild", version: "0.25.12" }, + externals: [], + target: "browser", + sourceMaps: "hidden-external", + attributionMethod: "source-map-generated-spans-with-explicit-opaque-assets-v2", + }, + "/", + ), + ]; +} finally { + await browser.close(); +} + +const evidence = { + schemaVersion: 2, + kind: "first-paint-counterfactual", + candidate: { + fixtureOnly: true, + shipping: false, + deferredModules: [deferredModule], + }, + thresholds: { + brotliBytes: 7_680, + percent: 10, + timing: "median-and-p95-no-slower", + }, + fixtures, +}; +const evidencePath = resolve(repositoryRoot, "test-results/bundlers/first-paint.json"); +await writeFile(evidencePath, `${JSON.stringify(evidence, null, 2)}\n`); +await run( + "bun", + ["scripts/first-paint-evidence.ts", "check", relative(repositoryRoot, evidencePath)], + repositoryRoot, +); +console.log(`First-paint evidence: ${relative(repositoryRoot, evidencePath)}`); diff --git a/test/bundler-fixtures/first-paint/vite/index.html b/test/bundler-fixtures/first-paint/vite/index.html new file mode 100644 index 00000000..ae7bf8f7 --- /dev/null +++ b/test/bundler-fixtures/first-paint/vite/index.html @@ -0,0 +1,5 @@ + + + First paint fixture + + diff --git a/test/bundler-fixtures/first-paint/vite/node_modules b/test/bundler-fixtures/first-paint/vite/node_modules new file mode 120000 index 00000000..23816241 --- /dev/null +++ b/test/bundler-fixtures/first-paint/vite/node_modules @@ -0,0 +1 @@ +/home/vvin/learn/sheetwrite/test/bundler-fixtures/vite/node_modules \ No newline at end of file diff --git a/test/bundler-fixtures/first-paint/vite/src/main.ts b/test/bundler-fixtures/first-paint/vite/src/main.ts new file mode 100644 index 00000000..254891ea --- /dev/null +++ b/test/bundler-fixtures/first-paint/vite/src/main.ts @@ -0,0 +1,59 @@ +import { + type ColumnarData, + createGrid, + type Grid, + initSheetwrite, + type Workbook, +} from "@sheetwrite/core"; +import "@sheetwrite/core/styles.css"; + +declare global { + interface Window { + __sheetwriteFirstPaint?: { + grid: Grid; + readyMs: number; + surface: readonly ["createGrid", "initSheetwrite"]; + }; + } +} + +const workbook: Workbook = { + activeSheet: "first-paint", + sheets: [ + { + id: "first-paint", + name: "First paint", + rowCount: 3, + columns: [ + { key: "name", header: "Name", width: 160, type: "text" }, + { key: "amount", header: "Amount", width: 120, type: "number" }, + ], + }, + ], +}; +const data: ColumnarData = { + rowCount: 3, + columns: { + name: ["Alpha", "Beta", "Gamma"], + amount: new Float64Array([1, 2, 3]), + }, +}; + +await initSheetwrite(); +const host = document.createElement("div"); +host.dataset.sheetwriteFirstPaintHost = ""; +host.style.cssText = "width:640px;height:320px"; +document.body.append(host); +const grid = createGrid(host, { + data, + workbook, + config: { contextMenu: false, find: false, toolbar: false }, +}); +await new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())), +); +window.__sheetwriteFirstPaint = { + grid, + readyMs: performance.now(), + surface: ["createGrid", "initSheetwrite"], +}; diff --git a/test/bundler-fixtures/first-paint/vite/vite.config.ts b/test/bundler-fixtures/first-paint/vite/vite.config.ts new file mode 100644 index 00000000..977d9aea --- /dev/null +++ b/test/bundler-fixtures/first-paint/vite/vite.config.ts @@ -0,0 +1,22 @@ +import { resolve } from "node:path"; +import { defineConfig } from "vite"; + +const output = process.env.SHEETWRITE_FIRST_PAINT_OUTPUT; +const core = process.env.SHEETWRITE_FIRST_PAINT_CORE; +if (!output || !core) { + throw new Error("SHEETWRITE_FIRST_PAINT_OUTPUT and SHEETWRITE_FIRST_PAINT_CORE are required"); +} + +export default defineConfig({ + resolve: { + alias: { + "@sheetwrite/core": resolve(core), + }, + }, + build: { + manifest: true, + minify: "esbuild", + outDir: resolve(output), + sourcemap: "hidden", + }, +}); diff --git a/test/bundler-fixtures/module-attribution.mjs b/test/bundler-fixtures/module-attribution.mjs new file mode 100644 index 00000000..96d7f532 --- /dev/null +++ b/test/bundler-fixtures/module-attribution.mjs @@ -0,0 +1,272 @@ +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { dirname, relative, resolve, sep } from "node:path"; +import { brotliCompressSync, constants, gzipSync } from "node:zlib"; + +const SOURCE_MAPS_MODE = "hidden-external"; +const BASE64 = Object.fromEntries( + [..."ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"].map( + (character, index) => [character, index], + ), +); +const ATTRIBUTION_METHOD = "source-map-generated-spans-with-explicit-opaque-assets-v2"; + +function compressedSizes(data) { + return { + rawBytes: data.byteLength, + gzipBytes: gzipSync(data, { level: 9, mtime: 0 }).byteLength, + brotliBytes: brotliCompressSync(data, { + params: { + [constants.BROTLI_PARAM_MODE]: constants.BROTLI_MODE_GENERIC, + [constants.BROTLI_PARAM_QUALITY]: 11, + [constants.BROTLI_PARAM_SIZE_HINT]: data.byteLength, + }, + }).byteLength, + }; +} + +function decodeVlq(segment) { + const values = []; + let value = 0; + let shift = 0; + for (const character of segment) { + const digit = BASE64[character]; + if (digit === undefined) throw new Error(`Invalid source-map VLQ character ${character}`); + value += (digit & 31) << shift; + if ((digit & 32) !== 0) { + shift += 5; + continue; + } + const negative = (value & 1) === 1; + const decoded = value >> 1; + values.push(negative ? -decoded : decoded); + value = 0; + shift = 0; + } + if (shift !== 0) throw new Error(`Truncated source-map VLQ segment ${segment}`); + return values; +} + +function decodedMappings(map) { + if (map.version !== 3 || !Array.isArray(map.sources) || typeof map.mappings !== "string") { + throw new Error("Module attribution requires a non-indexed source map v3"); + } + const lines = []; + let sourceIndex = 0; + for (const encodedLine of map.mappings.split(";")) { + let generatedColumn = 0; + const mappings = []; + for (const encodedSegment of encodedLine.split(",")) { + if (encodedSegment.length === 0) continue; + const fields = decodeVlq(encodedSegment); + if (fields.length !== 1 && fields.length !== 4 && fields.length !== 5) { + throw new Error(`Unsupported source-map segment width ${fields.length}`); + } + generatedColumn += fields[0]; + let source; + if (fields.length >= 4) { + sourceIndex += fields[1]; + source = map.sources[sourceIndex]; + if (typeof source !== "string") { + throw new Error(`Source-map segment references missing source ${sourceIndex}`); + } + } + mappings.push({ generatedColumn, source }); + } + lines.push(mappings); + } + return lines; +} + +function normalizeSource(source, bundler) { + const decoded = decodeURIComponent(source).replaceAll("\\", "/").split("!").at(-1).split("?")[0]; + const nodeModules = decoded.lastIndexOf("/node_modules/"); + if (nodeModules >= 0) { + const packagePath = decoded + .slice(nodeModules + "/node_modules/".length) + .replace(/^\.pnpm\/[^/]+\/node_modules\//, ""); + const segments = packagePath.split("/").filter(Boolean); + const packageName = segments[0]?.startsWith("@") + ? `${segments[0]}/${segments[1] ?? "missing"}` + : (segments[0] ?? "missing"); + return { id: packagePath, owner: packageName }; + } + const workspaceCore = decoded.lastIndexOf("/packages/core/"); + if (workspaceCore >= 0) { + return { + id: `@sheetwrite/core/${decoded.slice(workspaceCore + "/packages/core/".length)}`, + owner: "@sheetwrite/core", + }; + } + const fixture = decoded.lastIndexOf("/test/bundler-fixtures/"); + if (fixture >= 0) { + return { + id: `fixture/${decoded.slice(fixture + "/test/bundler-fixtures/".length)}`, + owner: "fixture", + }; + } + const sourceIndex = decoded.lastIndexOf("/src/"); + if (sourceIndex >= 0) { + return { id: `fixture${decoded.slice(sourceIndex)}`, owner: "fixture" }; + } + const stableFixture = decoded.replace(/^webpack:\/\/[^/]*\//, "").replace(/^\.\//, ""); + if (stableFixture.startsWith("app/")) { + return { id: `fixture/${stableFixture}`, owner: "fixture" }; + } + const stable = decoded + .replace(/^webpack:\/\/[^/]*\//, "") + .replace(/^file:\/\//, "") + .replace(/^(?:\.\.\/)+/, "") + .replace(/^\.\//, "") + .replaceAll("\0", "virtual:"); + return { + id: + stable.length === 0 + ? `${bundler}:runtime` + : stable.startsWith("/") + ? `${bundler}:runtime/absolute` + : `${bundler}:runtime/${stable}`, + owner: `${bundler}:runtime`, + }; +} + +async function sourceMapFor(assetPath, source) { + const match = source.match(/\n?\/\/[#@] sourceMappingURL=([^\s]+)\s*$/); + const mapPath = + match === null || match[1].startsWith("data:") + ? `${assetPath}.map` + : resolve(dirname(assetPath), decodeURIComponent(match[1])); + try { + return JSON.parse(await readFile(mapPath, "utf8")); + } catch (error) { + throw new Error(`Module attribution requires an external source map for ${assetPath}`, { + cause: error, + }); + } +} + +function appendSpan(spans, identity, text) { + if (text.length === 0) return; + const key = `${identity.owner}\0${identity.id}`; + const previous = spans.get(key); + if (previous === undefined) spans.set(key, { ...identity, parts: [text] }); + else previous.parts.push(text); +} + +function attributeSource(source, map, bundler) { + const spans = new Map(); + const mappingLines = decodedMappings(map); + const generatedLines = source.split("\n"); + const runtime = { id: `${bundler}:runtime`, owner: `${bundler}:runtime` }; + for (let lineIndex = 0; lineIndex < generatedLines.length; lineIndex++) { + const line = generatedLines[lineIndex] + (lineIndex + 1 < generatedLines.length ? "\n" : ""); + const contentLength = generatedLines[lineIndex].length; + const mappings = mappingLines[lineIndex] ?? []; + let cursor = 0; + let owner = runtime; + for (const mapping of mappings) { + if (mapping.generatedColumn < cursor || mapping.generatedColumn > contentLength) { + throw new Error(`Source-map generated column escapes line ${lineIndex + 1}`); + } + appendSpan(spans, owner, line.slice(cursor, mapping.generatedColumn)); + owner = + mapping.source === undefined + ? runtime + : normalizeSource(`${map.sourceRoot ?? ""}${mapping.source}`, bundler); + cursor = mapping.generatedColumn; + } + appendSpan(spans, owner, line.slice(cursor)); + } + return spans; +} + +function mergeSpans(target, source) { + for (const [key, identity] of source) { + const previous = target.get(key); + if (previous === undefined) target.set(key, identity); + else previous.parts.push(...identity.parts); + } +} + +export async function attributeEntry({ + assetFiles, + bundler, + eagerImports, + opaqueAssetFiles = [], + entry, + name, + repositoryRoot, + root, +}) { + const selected = [...new Set(assetFiles)].sort(); + const opaque = new Set(opaqueAssetFiles); + for (const file of opaque) { + if (!selected.includes(file)) { + throw new Error(`${bundler} ${name} declares a non-initial opaque asset ${file}`); + } + } + if (selected.length === 0) throw new Error(`${bundler} ${name} has no initial JavaScript assets`); + const spans = new Map(); + let generatedBytes = 0; + const attributedAssets = []; + for (const file of selected) { + if (!file.endsWith(".js")) throw new Error(`Cannot attribute non-JavaScript asset ${file}`); + const assetPath = resolve(root, file); + if (assetPath !== root && !assetPath.startsWith(`${root}${sep}`)) { + throw new Error(`Attribution asset escapes fixture output: ${file}`); + } + const content = await readFile(assetPath); + const source = content.toString("utf8"); + generatedBytes += content.byteLength; + attributedAssets.push({ + path: relative(repositoryRoot, assetPath).replaceAll("\\", "/"), + sha256: createHash("sha256").update(content).digest("hex"), + }); + if (opaque.has(file)) { + appendSpan( + spans, + { id: `${bundler}:opaque/${file}`, owner: `${bundler}:opaque-framework` }, + source, + ); + } else { + mergeSpans(spans, attributeSource(source, await sourceMapFor(assetPath, source), bundler)); + } + } + const modules = [...spans.values()] + .map(({ id, owner, parts }) => ({ + id, + owner, + attribution: owner === `${bundler}:opaque-framework` ? "opaque-asset" : "source-map", + ...compressedSizes(Buffer.from(parts.join(""))), + })) + .filter((module) => module.rawBytes > 0) + .sort( + (left, right) => left.id.localeCompare(right.id) || left.owner.localeCompare(right.owner), + ); + const attributedBytes = modules.reduce((sum, module) => sum + module.rawBytes, 0); + if (attributedBytes !== generatedBytes) { + throw new Error( + `${bundler} ${name} attributed ${attributedBytes} of ${generatedBytes} generated bytes`, + ); + } + return { + name, + entry, + eagerImports: [...new Set(eagerImports)].sort(), + assets: attributedAssets, + generatedBytes, + modules, + }; +} + +export function attributionProvenance({ minifier, version }) { + return { + buildMode: "production", + minified: true, + minifier: { name: minifier, version }, + externals: [], + target: "browser", + sourceMaps: SOURCE_MAPS_MODE, + attributionMethod: ATTRIBUTION_METHOD, + }; +} diff --git a/test/bundler-fixtures/next/app/attribution/page.tsx b/test/bundler-fixtures/next/app/attribution/page.tsx new file mode 100644 index 00000000..6aed753c --- /dev/null +++ b/test/bundler-fixtures/next/app/attribution/page.tsx @@ -0,0 +1,11 @@ +"use client"; + +import { createGrid, initSheetwrite } from "@sheetwrite/core"; + +const eagerImports = [createGrid, initSheetwrite] as const; + +export default function AttributionPage() { + return ( + {eagerImports.map((value) => value.name).join(",")} + ); +} diff --git a/test/bundler-fixtures/next/check.mjs b/test/bundler-fixtures/next/check.mjs index 8633ced9..441d518f 100644 --- a/test/bundler-fixtures/next/check.mjs +++ b/test/bundler-fixtures/next/check.mjs @@ -1,5 +1,6 @@ import { mkdir, readdir, readFile, writeFile } from "node:fs/promises"; import { relative, resolve } from "node:path"; +import { attributeEntry, attributionProvenance } from "../module-attribution.mjs"; const repositoryRoot = resolve("../../.."); const nextRoot = resolve(".next"); @@ -26,31 +27,63 @@ const wasm = staticAssets.filter((path) => path.endsWith(".wasm")); if (wasm.length !== 1) throw new Error(`Expected one browser Next.js WASM asset, got ${wasm.length}`); const selected = [...new Set(staticAssets)].sort(); -const html = await readFile(resolve("out/index.html"), "utf8"); -const initialUrls = new Set( - [...html.matchAll(/(?:src|href)="([^"]+\.(?:css|js|wasm)(?:\?[^"]*)?)"/g)].map((match) => - match[1].split("?")[0].replace(/^\/_next\//, ""), - ), +function initialUrls(html) { + return new Set( + [...html.matchAll(/(?:src|href)="([^"]+\.(?:css|js|wasm)(?:\?[^"]*)?)"/g)].map((match) => + match[1].split("?")[0].replace(/^\/_next\//, ""), + ), + ); +} + +const lifecycleInitialUrls = initialUrls(await readFile(resolve("out/index.html"), "utf8")); +const attributionHtml = await readFile(resolve("out/attribution.html"), "utf8").catch(() => + readFile(resolve("out/attribution/index.html"), "utf8"), ); +const attributionInitialUrls = initialUrls(attributionHtml); const assets = selected.map((path) => { const nextRelative = relative(nextRoot, path).replaceAll("\\", "/"); const kind = path.endsWith(".js") ? "javascript" : path.endsWith(".css") ? "css" : "wasm"; - const isInitial = kind === "wasm" || initialUrls.has(nextRelative); + const roles = []; + if (kind === "wasm" || lifecycleInitialUrls.has(nextRelative)) roles.push("core-initial"); + if (attributionInitialUrls.has(nextRelative)) roles.push("core-attribution-initial"); + if (roles.length === 0) roles.push("next-async"); return { path: relative(repositoryRoot, path).replaceAll("\\", "/"), kind, - owner: isInitial ? "core-initial" : "next-async", - roles: [isInitial ? "core-initial" : "next-async"], + owner: roles.length === 1 ? roles[0] : `shared:${roles.join("+")}`, + roles, }; }); -if (!assets.some((asset) => asset.kind === "javascript" && asset.owner === "core-initial")) { +if (!assets.some((asset) => asset.kind === "javascript" && asset.roles.includes("core-initial"))) { throw new Error("Next.js manifest identified no initial browser JavaScript"); } +const attributionAssetFiles = selected + .map((path) => relative(nextRoot, path).replaceAll("\\", "/")) + .filter((path) => path.endsWith(".js") && attributionInitialUrls.has(path)); +const attribution = [ + await attributeEntry({ + assetFiles: attributionAssetFiles, + bundler: "next", + eagerImports: ["@sheetwrite/core#createGrid", "@sheetwrite/core#initSheetwrite"], + entry: "app/attribution/page.tsx", + name: "core-first-paint", + opaqueAssetFiles: attributionAssetFiles.filter((path) => + /(?:^|\/)polyfills-[^/]+\.js$/.test(path), + ), + repositoryRoot, + root: nextRoot, + }), +]; const evidence = { - schemaVersion: 1, + schemaVersion: 2, bundler: "next", version: packageManifest.dependencies.next, assets, + provenance: attributionProvenance({ + minifier: "next-swc", + version: packageManifest.dependencies.next, + }), + attribution, }; const evidencePath = resolve(repositoryRoot, "test-results/bundlers/next.json"); await mkdir(resolve(evidencePath, ".."), { recursive: true }); diff --git a/test/bundler-fixtures/next/next.config.mjs b/test/bundler-fixtures/next/next.config.mjs index b62e5573..6574d11c 100644 --- a/test/bundler-fixtures/next/next.config.mjs +++ b/test/bundler-fixtures/next/next.config.mjs @@ -1,7 +1,12 @@ /** @type {import('next').NextConfig} */ const nextConfig = { output: "export", + productionBrowserSourceMaps: true, outputFileTracingRoot: import.meta.dirname, + webpack(config, { dev, isServer }) { + if (!dev && !isServer) config.devtool = "hidden-source-map"; + return config; + }, }; export default nextConfig; diff --git a/test/bundler-fixtures/run.mjs b/test/bundler-fixtures/run.mjs index 572c9512..cbbbec6c 100644 --- a/test/bundler-fixtures/run.mjs +++ b/test/bundler-fixtures/run.mjs @@ -1,8 +1,8 @@ import { spawn } from "node:child_process"; import { createHash } from "node:crypto"; -import { cp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { cp, glob, mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { createServer } from "node:http"; -import { basename, extname, join, resolve, sep } from "node:path"; +import { basename, dirname, extname, join, resolve, sep } from "node:path"; import { fileURLToPath } from "node:url"; import { chromium } from "@playwright/test"; import { bindCanonicalTarballIntegrities } from "../../scripts/release-lock-integrity.mjs"; @@ -66,6 +66,21 @@ function run(command, args, cwd) { }); } +async function copyPublishEntry(sourceRoot, packageRoot, entry) { + if (!/[*?[\]{}]/u.test(entry)) { + await cp(join(sourceRoot, entry), join(packageRoot, entry), { recursive: true }); + return; + } + const matches = []; + for await (const path of glob(entry, { cwd: sourceRoot })) matches.push(path); + if (matches.length === 0) throw new Error(`Package file pattern matched nothing: ${entry}`); + for (const path of matches) { + const target = join(packageRoot, path); + await mkdir(dirname(target), { recursive: true }); + await cp(join(sourceRoot, path), target); + } +} + async function stageAndPack(packageDirectory, filename) { const sourceRoot = join(repositoryRoot, packageDirectory); const packageRoot = join(stagingRoot, basename(packageDirectory)); @@ -73,7 +88,7 @@ async function stageAndPack(packageDirectory, filename) { const publishFiles = [...(manifest.files ?? []), "README.md"]; await mkdir(packageRoot, { recursive: true }); for (const path of publishFiles) { - await cp(join(sourceRoot, path), join(packageRoot, path), { recursive: true }); + await copyPublishEntry(sourceRoot, packageRoot, path); } await cp(join(repositoryRoot, "LICENSE"), join(packageRoot, "LICENSE")); if (manifest.dependencies) { diff --git a/test/bundler-fixtures/vite/check.mjs b/test/bundler-fixtures/vite/check.mjs index 2bf1ebbe..2d4f88eb 100644 --- a/test/bundler-fixtures/vite/check.mjs +++ b/test/bundler-fixtures/vite/check.mjs @@ -1,10 +1,12 @@ import { mkdir, readdir, readFile, writeFile } from "node:fs/promises"; import { relative, resolve } from "node:path"; +import { attributeEntry, attributionProvenance } from "../module-attribution.mjs"; const repositoryRoot = resolve("../../.."); const root = resolve("dist"); const manifest = JSON.parse(await readFile(resolve(root, ".vite/manifest.json"), "utf8")); const packageManifest = JSON.parse(await readFile("package.json", "utf8")); +const packageLock = JSON.parse(await readFile("package-lock.json", "utf8")); const rolesByFile = new Map(); const kindByFile = new Map(); const initialRoles = ["core-initial", "react-initial", "svelte-initial", "vue-initial"]; @@ -82,8 +84,11 @@ const entryRoles = { "xlsx.html": "xlsx-loader-initial", }; const defaultInitialChunks = new Set(); +const staticGraphsByHtml = new Map(); for (const [html, role] of Object.entries(entryRoles)) { - for (const key of staticGraph(findEntry(html))) { + const graph = staticGraph(findEntry(html)); + staticGraphsByHtml.set(html, graph); + for (const key of graph) { addChunk(key, role); if (initialRoles.includes(role)) defaultInitialChunks.add(key); } @@ -157,11 +162,30 @@ const assets = publicFiles.map((file) => { roles, }; }); +const coreGraph = staticGraphsByHtml.get("index.html"); +if (coreGraph === undefined) throw new Error("Vite core entry graph was not recorded"); +const attribution = [ + await attributeEntry({ + assetFiles: [...coreGraph] + .map((key) => manifest[key]?.file) + .filter((file) => typeof file === "string" && file.endsWith(".js")), + bundler: "vite", + eagerImports: ["@sheetwrite/core#createGrid", "@sheetwrite/core#initSheetwrite"], + entry: "index.html", + name: "core-first-paint", + repositoryRoot, + root, + }), +]; +const esbuildVersion = packageLock.packages?.["node_modules/esbuild"]?.version; +if (typeof esbuildVersion !== "string") throw new Error("Vite fixture lock is missing esbuild"); const evidence = { - schemaVersion: 1, + schemaVersion: 2, bundler: "vite", version: packageManifest.dependencies.vite, assets, + provenance: attributionProvenance({ minifier: "esbuild", version: esbuildVersion }), + attribution, }; const evidencePath = resolve(repositoryRoot, "test-results/bundlers/vite.json"); await mkdir(resolve(evidencePath, ".."), { recursive: true }); diff --git a/test/bundler-fixtures/vite/vite.config.ts b/test/bundler-fixtures/vite/vite.config.ts index 768af3b6..c277d22b 100644 --- a/test/bundler-fixtures/vite/vite.config.ts +++ b/test/bundler-fixtures/vite/vite.config.ts @@ -5,7 +5,9 @@ import { defineConfig } from "vite"; export default defineConfig({ plugins: [svelte()], build: { + sourcemap: "hidden", manifest: true, + minify: "esbuild", rollupOptions: { input: { core: resolve(import.meta.dirname, "index.html"), diff --git a/test/bundler-fixtures/webpack/check.mjs b/test/bundler-fixtures/webpack/check.mjs index 9e828f28..c9257d8e 100644 --- a/test/bundler-fixtures/webpack/check.mjs +++ b/test/bundler-fixtures/webpack/check.mjs @@ -1,9 +1,11 @@ import { mkdir, readdir, readFile, writeFile } from "node:fs/promises"; import { relative, resolve } from "node:path"; +import { attributeEntry, attributionProvenance } from "../module-attribution.mjs"; const repositoryRoot = resolve("../../.."); const root = resolve("dist"); const packageManifest = JSON.parse(await readFile("package.json", "utf8")); +const packageLock = JSON.parse(await readFile("package-lock.json", "utf8")); async function walk(directory) { const entries = await readdir(directory, { withFileTypes: true }); @@ -49,11 +51,31 @@ const assets = publicFiles.map((file) => { roles, }; }); +const attribution = [ + await attributeEntry({ + assetFiles: ["main.js"], + bundler: "webpack", + eagerImports: ["@sheetwrite/core#initSheetwrite"], + entry: "src/main.js", + name: "core-first-paint", + repositoryRoot, + root, + }), +]; +const terserVersion = packageLock.packages?.["node_modules/terser-webpack-plugin"]?.version; +if (typeof terserVersion !== "string") { + throw new Error("webpack fixture lock is missing terser-webpack-plugin"); +} const evidence = { - schemaVersion: 1, + schemaVersion: 2, bundler: "webpack", version: packageManifest.dependencies.webpack, assets, + provenance: attributionProvenance({ + minifier: "terser-webpack-plugin", + version: terserVersion, + }), + attribution, }; const evidencePath = resolve(repositoryRoot, "test-results/bundlers/webpack.json"); await mkdir(resolve(evidencePath, ".."), { recursive: true }); diff --git a/test/bundler-fixtures/webpack/webpack.config.js b/test/bundler-fixtures/webpack/webpack.config.js index 5b1c2d01..25aa339a 100644 --- a/test/bundler-fixtures/webpack/webpack.config.js +++ b/test/bundler-fixtures/webpack/webpack.config.js @@ -3,7 +3,7 @@ import { resolve } from "node:path"; export default { mode: "production", entry: "./src/main.js", - devtool: false, + devtool: "hidden-source-map", output: { path: resolve("dist"), filename: "main.js",
Entry pointClassificationSymbols
@sheetwrite/coresupported314
@sheetwrite/coresupported312
@sheetwrite/core/adaptersupported51
@sheetwrite/core/browsersupported4
@sheetwrite/core/shellsupported13