diff --git a/changelog.d/10228-sso-utf16-length.md b/changelog.d/10228-sso-utf16-length.md
new file mode 100644
index 0000000000..838406e462
--- /dev/null
+++ b/changelog.d/10228-sso-utf16-length.md
@@ -0,0 +1,5 @@
+Fix `.length` on short inline strings containing non-ASCII text. Runtime
+property reads, suffix cursors, typed and generic generated reads, and
+element-shape loop clones now count UTF-16 code units instead of UTF-8 bytes.
+Non-ASCII strings remain eligible for inline storage; the generated ASCII
+path uses the stored byte count, and the Unicode path stays call-free.
diff --git a/crates/perry-codegen/src/expr/element_shape_reads.rs b/crates/perry-codegen/src/expr/element_shape_reads.rs
index 9d78d32174..7cf8c06ddf 100644
--- a/crates/perry-codegen/src/expr/element_shape_reads.rs
+++ b/crates/perry-codegen/src/expr/element_shape_reads.rs
@@ -33,14 +33,9 @@
//! count in `StringHeader::utf16_len`, the leading `u32` — the identical load
//! the inline `.length` fast path in `property_get/generic_dispatch.rs` emits.
//! An SSO immediate (`SHORT_STRING_TAG`, up to five bytes packed into the
-//! NaN-box) keeps its BYTE length in bits 40..=47, and this reads it the same
-//! way the runtime's own SSO `.length` arms do
-//! (`string/char_ops.rs::string_property_get_miss`,
-//! `property_get/generic_dispatch.rs`). That convention is byte length, not
-//! code-unit length, so a non-ASCII SSO string reports its UTF-8 size — a
-//! PRE-EXISTING Perry-wide answer, reproduced here deliberately: the clone must
-//! agree with the path it is a clone of, and diverging from it would be a
-//! miscompile even where the shared answer is itself wrong.
+//! NaN-box) keeps its byte length in bits 40..=47. The shared SSO length
+//! lowering returns that count for ASCII and counts UTF-16 units inline for
+//! non-ASCII payloads, matching heap strings without adding a call (#10191).
//!
//! **The ternary.** JS truthiness of an arbitrary value is a runtime question
//! (`""`, `0`, `NaN`, `null`, every object). The clone does not guess it: only
@@ -58,8 +53,6 @@ use crate::types::{DOUBLE, I1, I32, I64};
const STRING_TAG_TOP16: &str = crate::nanbox::STRING_TAG_TOP16_I64;
/// `SHORT_STRING_TAG >> 48` — an SSO immediate.
const SHORT_STRING_TAG_TOP16: &str = crate::nanbox::SHORT_STRING_TAG_TOP16_I64;
-/// `SHORT_STRING_LEN_SHIFT` — the length byte sits at bits 40..=47.
-const SHORT_STRING_LEN_SHIFT: &str = "40";
/// `TAG_TRUE` (`0x7FFC_0000_0000_0004`) as a decimal i64 literal.
const TAG_TRUE_I64: &str = "9222246136947933188";
@@ -212,9 +205,7 @@ pub(crate) fn lower_cloned_string_length(
ctx.block().br(&done_label);
ctx.current_block = sso_idx;
- let sso_shifted = ctx.block().lshr(I64, &bits, SHORT_STRING_LEN_SHIFT);
- let sso_len_byte = ctx.block().and(I64, &sso_shifted, "255");
- let sso_len = ctx.block().uitofp(I64, &sso_len_byte, DOUBLE);
+ let sso_len = super::string_length::lower_sso_length(ctx, &bits);
let sso_end = ctx.block().label.clone();
ctx.block().br(&done_label);
@@ -311,7 +302,6 @@ mod tests {
fn string_tag_literals_match_the_runtime() {
assert_eq!(STRING_TAG_TOP16, (0x7FFFu64).to_string());
assert_eq!(SHORT_STRING_TAG_TOP16, (0x7FF9u64).to_string());
- assert_eq!(SHORT_STRING_LEN_SHIFT, "40");
}
#[test]
diff --git a/crates/perry-codegen/src/expr/property_get/generic_dispatch.rs b/crates/perry-codegen/src/expr/property_get/generic_dispatch.rs
index 889e059eaa..31bdf5c1bb 100644
--- a/crates/perry-codegen/src/expr/property_get/generic_dispatch.rs
+++ b/crates/perry-codegen/src/expr/property_get/generic_dispatch.rs
@@ -306,13 +306,9 @@ pub(crate) fn lower_generic_property_get(
let is_sso = ctx.block().icmp_eq(I64, &obj_tag, "32761"); // 0x7FF9
ctx.block().cond_br(&is_sso, &sso_label, &nonptr_label);
- // `.length` of an SSO string is the length byte in bits 40..47 of the
- // NaN-box itself — the same extract `js_object_get_field_by_name_f64`
- // performs, minus the call and the key decode.
+ // SSO stores a byte count; JavaScript observes UTF-16 code units.
ctx.current_block = sso_idx;
- let len_shifted = ctx.block().lshr(I64, &obj_bits, "40");
- let len_byte = ctx.block().and(I64, &len_shifted, "255");
- let sso_val = ctx.block().uitofp(I64, &len_byte, DOUBLE);
+ let sso_val = super::super::string_length::lower_sso_length(ctx, &obj_bits);
let sso_end_label = ctx.block().label.clone();
ctx.block().br(&merge_label);
ctx.current_block = nonptr_idx;
diff --git a/crates/perry-codegen/src/expr/property_get/tests.rs b/crates/perry-codegen/src/expr/property_get/tests.rs
index e9a3b0f654..de53c55661 100644
--- a/crates/perry-codegen/src/expr/property_get/tests.rs
+++ b/crates/perry-codegen/src/expr/property_get/tests.rs
@@ -1006,6 +1006,10 @@ fn generic_length_read_serves_a_string_inline() {
.map(|i| i + 1)
.unwrap_or(sso_body.len());
let sso_body = &sso_body[..sso_end];
+ assert!(
+ ir.contains("\nsso.utf16") && ir.contains("\nsso.length.done"),
+ "non-ASCII inline strings must have a UTF-16 counting arm:\n{ir}"
+ );
assert!(
sso_body.contains("lshr i64") && sso_body.contains(", 40"),
"the SSO arm must extract the inline length byte, not call the \
diff --git a/crates/perry-codegen/src/expr/string_length.rs b/crates/perry-codegen/src/expr/string_length.rs
index 90fb11e7b6..d092999da7 100644
--- a/crates/perry-codegen/src/expr/string_length.rs
+++ b/crates/perry-codegen/src/expr/string_length.rs
@@ -5,11 +5,11 @@ use perry_hir::Expr;
use crate::nanbox::POINTER_MASK_I64;
use crate::type_analysis::{is_array_expr, is_string_expr, string_value_is_runtime_guaranteed};
-use crate::types::{DOUBLE, I32, I64};
+use crate::types::{DOUBLE, I1, I32, I64};
use super::{lower_expr, static_string_lowering_enabled, FnCtx};
-/// Lower string `.length` as SSO-byte extraction or a heap-header load.
+/// Lower string `.length` as an inline UTF-16 count or a heap-header load.
///
/// A declared type is only a dispatch candidate, so its miss retains ordinary
/// property semantics. A constructive proof (including the guarded string
@@ -44,9 +44,7 @@ pub(crate) fn try_lower(ctx: &mut FnCtx<'_>, object: &Expr) -> Result