Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions apps/benchmarks/src/benchmark/package-size-budgets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,11 @@ export const packageSizeBudgets = {
// Complete Rust shaping, layout, policy, and command-plan publication. Gzip and Brotli rose for the decoration
// rendering feature (D-248): decorating-box cascade state, per-cluster run aggregation, resource-free plan rows,
// and paint-order gather passes, at roughly +0.6 KB gzip and +1.1 KB Brotli against a raw total that stayed
// inside the golfed ceiling.
// inside the golfed ceiling. Raw rose ~50 bytes for the shaping-run topology guard in prepare_shape (reshape
// when a metric-only restyle changes run-merge boundaries); compressed sizes stayed inside their budgets.
'text-shaper-wasm': {
rawBytes: 1_107_000,
minifiedBytes: 1_107_000,
rawBytes: 1_107_500,
minifiedBytes: 1_107_500,
gzipBytes: 429_000,
brotliBytes: 339_500,
},
Expand Down
10 changes: 5 additions & 5 deletions apps/benchmarks/src/generated/package-sizes.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,11 @@
"label": "Shaper Wasm",
"status": "measured",
"format": "wasm",
"sha256": "0d5e4ffc85d8e8c94a58216e816a3af5a4cacbeaf23aa4bcea887c0cd6f9e76b",
"rawBytes": 1106941,
"minifiedBytes": 1106941,
"gzipBytes": 428615,
"brotliBytes": 339142
"sha256": "682f902937b32b6cd5954829a586a764b875b45d18fc25c37f6b6bd2fa1ed1a8",
"rawBytes": 1107042,
"minifiedBytes": 1107042,
"gzipBytes": 428641,
"brotliBytes": 338830
},
{
"id": "three-runtime-js",
Expand Down
12 changes: 12 additions & 0 deletions docs/log.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,18 @@

## 2026-08-11

- **Shaping-run topology guard** — Fixed a session-poisoning engine defect the presentation soak exposed: a
metrics-only restyle (fontSize) rebuilds the pending shaping-run table, and because run merging compares layout
scalars including `font_size`, a span crossing value-equality with its neighbor changes the run topology without
any shaping invalidation. `prepare_shape` retained the stale merged shape against the rebuilt table, glyphless
run ownership rejected the mismatch as `invalidRequest`, and the session never recovered. The fix compares
committed and pending run boundaries in `prepare_shape` and reshapes on any mismatch. Proven by a red-green
integration regression (animated span fontSize passing through root equality), byte-exact replay of the four
captured production frames including the failing one, and a clean full presentation soak. Debugging missteps
recorded: `Option::ok_or` evaluates its error argument eagerly, which silently broke a poisoned-site tracker
until switched to `ok_or_else`, and the first regression attempt passed because a fontSize-only span at equality
collapses to one style segment — the span needs a paint difference to keep segments split while runs merge.

- **Decoration rendering (D-248)** — Underline, overline, and line-through render end-to-end from baked font
metrics: public span `decoration` styles, decorating-box cascade stamping, continuous merged lines across nested
spans, CSS paint order via record placement, resource-free decoration draws through both planners, and one shared
Expand Down
2 changes: 1 addition & 1 deletion docs/packages/benchmarks.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ description: Provides the shared interactive and automated benchmark product sur
resource: ../../apps/benchmarks
workspace_package: '@pmndrs/text-benchmarks'
documentation_type: reference
source_digest: 'sha256:28a3fe3c5278e7412cdf26894a208dca2dfecd856724bdf2fbdc1c46a7b50f70'
source_digest: 'sha256:142b0672f567a4110d280f68b48ed058e6398fbe73d07ff2b5eebcaa305e1458'
tags: [package, benchmarks, react, vite, product-e2e]
sources:
- id: manifest
Expand Down
2 changes: 1 addition & 1 deletion docs/packages/text.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ description: Implements portable font loading, retained Rust shaping and layout,
resource: ../../packages/text
workspace_package: '@pmndrs/text'
documentation_type: reference
source_digest: 'sha256:d7d4c376a07d2e237b35c6923ef22b7556c1c042618eb7e8a33b400e31028797'
source_digest: 'sha256:1a3b456d665fb179fb974d08ee23842d3a71527519e60f3d0173e9a16fcc2fbb'
tags: [package, public-api, rust, wasm, threejs, typography]
sources:
- id: manifest
Expand Down
26 changes: 24 additions & 2 deletions packages/text/rust/shaper/src/engine/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1914,8 +1914,20 @@ impl ParagraphState {
// Metric-only styles must refresh the retained run values consumed by cluster aggregation, but the underlying
// HarfRust result remains valid. Keeping those two invalidations distinct avoids reshaping on size, tracking,
// word-spacing, line-height, or baseline changes while still rebuilding advances from the new run styles.
if !self.shaping_runs_prepared
|| (!self.text_prepared && !self.style_invalidation.shaping && !self.bidi_prepared)
//
// Retention is only sound while the rebuilt run table matches the committed one. The
// shaping-run merge compares layout scalars, so a metric value crossing equality with
// its neighbor changes run topology without shaping invalidation — for example a span
// whose animated font size passes through the root's size while its paint keeps the
// style segment alive. Retaining the old shape against the new table desyncs cluster
// ownership and poisons the session, so a topology change falls through to a reshape.
if !self.shaping_runs_prepared {
return Ok(());
}
if !self.text_prepared
&& !self.style_invalidation.shaping
&& !self.bidi_prepared
&& same_run_boundaries(self.shaping_runs.runs(), self.pending_shaping_runs.runs())
{
return Ok(());
}
Expand Down Expand Up @@ -2963,6 +2975,16 @@ fn unicode_error(error: UnicodeError) -> EngineError {
}
}

/// Whether two run tables agree on run count and text ranges. Within a metrics-only
/// invalidation every shaping-affecting property is already known equal, so boundaries
/// are the only axis on which the rebuilt table can drift from the committed one.
fn same_run_boundaries(committed: &[ShapingRun], pending: &[ShapingRun]) -> bool {
committed.len() == pending.len()
&& committed.iter().zip(pending).all(|(committed, pending)| {
committed.text_start == pending.text_start && committed.text_end == pending.text_end
})
}

fn same_shaping_properties(left: ShapingRun, right: ShapingRun) -> bool {
left.script == right.script
&& left.direction == right.direction
Expand Down
117 changes: 117 additions & 0 deletions packages/text/tests/integration/three-engine-runtime.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1043,6 +1043,123 @@ test('Three coordinator shares shaping data across technique bindings and refere
decorationTarget.dispose();
decorationSession.dispose();

// Regression: a colored span whose fontSize animates through value-equality with
// its root keeps its style segment (the paint differs) while the shaping-run table
// merges across it (layout scalars match). When the next tick moves the size off
// equality the table splits again under metrics-only invalidation; the engine
// previously retained the merged shape against the rebuilt table and rejected
// every later frame with invalidRequest, poisoning the session.
const topologySession = coordinator.createSession({
requestCapacity: 8_192,
resultCapacity: 1024 * 1024,
textCapacity: 64,
});
const topologyStyles = (spanSize) => [
{
opcode: 'upsert',
paragraphId: 1,
styleId: 1,
cascadeOrder: 0,
start: 0,
end: 24,
root: true,
value: {
fontStackHandle: first.handle,
materialId: primaryMaterial.id,
fontSize: 16,
rasterPixelRatio: 1,
foregroundRgba: 0xffff_ffff,
},
},
{
opcode: 'upsert',
paragraphId: 1,
styleId: 2,
cascadeOrder: 1,
start: 8,
end: 16,
value: { fontSize: spanSize, foregroundRgba: 0xff44_22ff },
},
];
const topologyFrame = (previous, spanSize, extra = {}) =>
compileTextEngineFrameUpdate({
sessionId: topologySession.handle,
policyHandle: coordinator.policyHandle,
capabilitySet: 1,
expectedEngineRevision: previous?.engineRevision ?? 0,
consumedPlanRevision: previous?.planRevision ?? 0,
acknowledgedPublicationGeneration: previous?.publicationGeneration ?? 0,
limits: {
maxParagraphs: 1,
maxClusters: 32,
maxLines: 8,
maxRegions: 1,
maxExclusions: 1,
maxInlineObjects: 1,
maxSlotsPerBand: 2,
maxOutputBytes: 1024 * 1024,
},
styleMutations: topologyStyles(spanSize),
...extra,
});
// Frame 1: the span size equals the root — shaping runs merge across the span.
const topologyFirst = topologySession.update(
topologyFrame(undefined, 16, {
paragraphMutations: [{ opcode: 'upsert', paragraphId: 1, order: 0 }],
textMutations: [{ paragraphId: 1, start: 0, deleteCount: 0, insert: 'alpha beta gamma epsilon' }],
constraints: [
{
paragraphId: 1,
flowThreadId: 1,
geometryRevision: 1,
width: 512,
height: 128,
viewportBlockStart: 0,
viewportBlockEnd: 128,
resumeBlockOffset: 0,
maxLines: 8,
regionStart: 0,
resumeCluster: 0,
regionCount: 1,
resumeRegion: 0,
widthMode: 'at-most',
heightMode: 'at-most',
wrap: 'word',
align: 'start',
overflow: 'visible',
blockAlign: 'start',
},
],
regions: [
{
id: 1,
geometryRevision: 1,
shape: 'rectangle',
exclusionStart: 0,
exclusionCount: 0,
writingMode: 'horizontal-tb',
textOrientation: 'mixed',
inlineStart: 0,
blockStart: 0,
inlineEnd: 512,
blockEnd: 128,
clipInlineStart: 0,
clipBlockStart: 0,
clipInlineEnd: 512,
clipBlockEnd: 128,
},
],
}),
);
// Frame 2: the span size moves off equality — the run table splits again and the
// retained shape must be rebuilt, not reused.
const topologySecond = topologySession.update(topologyFrame(topologyFirst, 17.5));
assert.equal(topologySecond.engineRevision, topologyFirst.engineRevision + 1);
// Frame 3: and the session must remain healthy afterwards.
const topologyThird = topologySession.update(topologyFrame(topologySecond, 18.25));
assert.equal(topologyThird.engineRevision, topologySecond.engineRevision + 1);
topologySession.dispose();

target.dispose();
session.dispose();
first.release();
Expand Down
Loading