Skip to content
Open
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
1 change: 1 addition & 0 deletions apps/mobile/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,7 @@ const config: ExpoConfig = {
// target (which must exist before the compile phase can be attached).
...(!isIosPersonalTeamBuild ? ["./plugins/withWidgetLogoAsset.cjs", widgetsPlugin] : []),
"./plugins/withIosSceneLifecycle.cjs",
"./plugins/withIosCrashLog.cjs",
"./plugins/withAndroidCleartextTraffic.cjs",
"./plugins/withAndroidGradleHeap.cjs",
"./plugins/withAndroidModernPopupMenu.cjs",
Expand Down
4 changes: 4 additions & 0 deletions apps/mobile/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
// Installed via a side-effect import listed first so the fatal-error handler
// is in place before the rest of the app module graph evaluates.
import "./src/lib/installCrashLog";

import { registerRootComponent } from "expo";
import "react-native-gesture-handler";
import { featureFlags } from "react-native-screens";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,16 @@ struct T3MarkdownTextParagraphStyleRange {
Float firstLineHeadIndent;
Float headIndent;
Float paragraphSpacing;

bool operator==(const T3MarkdownTextParagraphStyleRange&) const = default;
};

struct T3MarkdownTextAttachmentRange {
size_t location;
size_t length;
std::string imageUri;

bool operator==(const T3MarkdownTextAttachmentRange&) const = default;
};

inline Float T3MarkdownTextAttachmentSize(const T3MarkdownTextAttachmentRange &) {
Expand All @@ -52,11 +56,6 @@ T3MarkdownTextStateReal> {
public:
using ConcreteViewShadowNode::ConcreteViewShadowNode;

T3MarkdownTextShadowNode(
const ShadowNode& sourceShadowNode,
const ShadowNodeFragment& fragment
);

static ShadowNodeTraits BaseTraits() {
auto traits = ConcreteViewShadowNode::BaseTraits();
traits.set(ShadowNodeTraits::Trait::LeafYogaNode);
Expand All @@ -71,8 +70,18 @@ T3MarkdownTextStateReal> {
const LayoutConstraints& layoutConstraints) const override;

private:
mutable AttributedString _attributedString;
mutable std::vector<T3MarkdownTextParagraphStyleRange> _paragraphStyleRanges;
mutable std::vector<T3MarkdownTextAttachmentRange> _attachmentRanges;
// Content must be derived from the current children whenever it is needed.
// Yoga can invoke layout() on a fresh clone without ever calling
// measureContent() on it (for example when both dimensions are already
// exact), so caching measure-time content in mutable members and publishing
// it from layout() lets state fall behind the children and drop text.
struct Content {
AttributedString attributedString;
std::vector<T3MarkdownTextParagraphStyleRange> paragraphStyleRanges;
std::vector<T3MarkdownTextAttachmentRange> attachmentRanges;
};

Content buildContent(const LayoutContext& layoutContext) const;
void updateStateIfNeeded(Content&& content);
};
} // namespace facebook::React
Original file line number Diff line number Diff line change
Expand Up @@ -72,15 +72,8 @@ static void applyAttachments(
}
}

T3MarkdownTextShadowNode::T3MarkdownTextShadowNode(
const ShadowNode& sourceShadowNode,
const ShadowNodeFragment& fragment
) : ConcreteViewShadowNode(sourceShadowNode, fragment) {
};

Size T3MarkdownTextShadowNode::measureContent(
const LayoutContext& layoutContext,
const LayoutConstraints& layoutConstraints) const {
T3MarkdownTextShadowNode::Content T3MarkdownTextShadowNode::buildContent(
const LayoutContext& layoutContext) const {
const auto &baseProps = getConcreteProps();

auto baseTextAttributes = TextAttributes::defaultTextAttributes();
Expand Down Expand Up @@ -207,14 +200,23 @@ static void applyAttachments(
}
}

_attributedString = baseAttributedString;
_paragraphStyleRanges = paragraphStyleRanges;
_attachmentRanges = attachmentRanges;
return Content{
std::move(baseAttributedString),
std::move(paragraphStyleRanges),
std::move(attachmentRanges),
};
}

Size T3MarkdownTextShadowNode::measureContent(
const LayoutContext& layoutContext,
const LayoutConstraints& layoutConstraints) const {
const auto &baseProps = getConcreteProps();
const auto content = buildContent(layoutContext);

NSMutableAttributedString *convertedAttributedString =
[RCTNSAttributedStringFromAttributedString(baseAttributedString) mutableCopy];
applyParagraphStyles(convertedAttributedString, paragraphStyleRanges);
applyAttachments(convertedAttributedString, attachmentRanges);
[RCTNSAttributedStringFromAttributedString(content.attributedString) mutableCopy];
applyParagraphStyles(convertedAttributedString, content.paragraphStyleRanges);
applyAttachments(convertedAttributedString, content.attachmentRanges);

const CGFloat maximumWidth = std::isfinite(layoutConstraints.maximumSize.width)
? layoutConstraints.maximumSize.width
Expand Down Expand Up @@ -255,10 +257,21 @@ static void applyAttachments(

void T3MarkdownTextShadowNode::layout(LayoutContext layoutContext) {
ensureUnsealed();
updateStateIfNeeded(buildContent(layoutContext));
}

void T3MarkdownTextShadowNode::updateStateIfNeeded(Content&& content) {
const auto &stateData = getStateData();
if (stateData.attributedString == content.attributedString &&
stateData.paragraphStyleRanges == content.paragraphStyleRanges &&
stateData.attachmentRanges == content.attachmentRanges) {
return;
}

setStateData(T3MarkdownTextStateReal{
_attributedString,
_paragraphStyleRanges,
_attachmentRanges,
std::move(content.attributedString),
std::move(content.paragraphStyleRanges),
std::move(content.attachmentRanges),
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export function SelectableMarkdownText({
skills = EMPTY_SKILLS,
textStyle,
highlightCode,
fillWidth = false,
preserveSoftBreaks = false,
onLinkPress,
marginTop = 0,
Expand Down Expand Up @@ -63,7 +64,15 @@ export function SelectableMarkdownText({
// shrink-to-fit containers such as user-message bubbles. Yoga then gives
// the native text node an unbounded second pass and the parent only clips
// the resulting single-line width instead of reflowing it.
<View style={{ flexShrink: 1, minWidth: 0, marginTop, marginBottom }}>
<View
style={{
flexShrink: 1,
minWidth: 0,
...(fillWidth ? { alignSelf: "stretch" } : null),
marginTop,
marginBottom,
}}
>
{chunks.map((chunk, index) => {
const content =
chunk.kind === "rich" ? (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export interface SelectableMarkdownTextProps {
readonly markdown: string;
readonly textStyle: NativeMarkdownTextStyle;
readonly highlightCode: MarkdownCodeHighlighter;
readonly fillWidth?: boolean;
readonly skills?: ReadonlyArray<SelectableMarkdownSkill>;
readonly preserveSoftBreaks?: boolean;
readonly onLinkPress?: (href: string) => void;
Expand Down
163 changes: 163 additions & 0 deletions apps/mobile/modules/t3-native-controls/ios/T3CrashLog.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import Foundation

/// Durable crash persistence for Documents/crash-logs.
///
/// The Expo module uses this for JS `writeSyncText`. AppDelegate installs
/// `RCTSetFatalHandler` and calls `persistNativeFatal` so reportFatal still
/// leaves a last-crash.json when the JS ErrorUtils path never flushes.
public enum T3CrashLog {
public static let directoryName = "crash-logs"
public static let lastCrashFileName = "last-crash.json"

private static let isoFormatter: ISO8601DateFormatter = {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return formatter
}()

/// Shared fsync write used by the Expo module and native fatal hooks.
@discardableResult
public static func writeSyncText(relativePath: String, contents: String) -> Bool {
do {
let docs = try FileManager.default.url(
for: .documentDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
)
let url = docs.appendingPathComponent(relativePath)
try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(),
withIntermediateDirectories: true
)
let data = Data(contents.utf8)
// Non-atomic: under abort we prefer a partial file over losing the rename.
try data.write(to: url, options: [])
let handle = try FileHandle(forWritingTo: url)
defer { try? handle.close() }
if #available(iOS 13.0, *) {
try handle.synchronize()
}
return true
} catch {
return false
}
}

/// Persist a native RCTFatal / RCTFatalException payload.
public static func persistNativeFatal(
message: String,
name: String,
stack: String?,
extra: String?,
source: String
) {
let truncatedMessage = truncate(message, max: 8_000)
let truncatedStack = stack.map { truncate($0, max: 48_000) }
let capturedAt = isoFormatter.string(from: Date())
let millis = Int(Date().timeIntervalSince1970 * 1000)

var record: [String: Any] = [
"breadcrumbs": [] as [Any],
"capturedAt": capturedAt,
"handlerInvocation": 0,
"isFatal": true,
"message": truncatedMessage,
"name": name,
"source": source,
]
if let truncatedStack, !truncatedStack.isEmpty {
record["stack"] = truncatedStack
}
if let extra, !extra.isEmpty {
record["extraData"] = truncate(extra, max: 8_000)
}

guard let data = try? JSONSerialization.data(withJSONObject: record, options: []),
let contents = String(data: data, encoding: .utf8)
else {
let escaped = truncatedMessage
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "\"", with: "\\\"")
.replacingOccurrences(of: "\n", with: "\\n")
let fallback =
"{\"capturedAt\":\"\(capturedAt)\",\"isFatal\":true,\"message\":\"\(escaped)\",\"name\":\"\(name)\",\"source\":\"\(source)\",\"handlerInvocation\":0,\"breadcrumbs\":[]}"
_ = writeSyncText(relativePath: "\(directoryName)/\(lastCrashFileName)", contents: fallback)
_ = writeSyncText(
relativePath: "\(directoryName)/crash-native-\(millis)-0.json",
contents: fallback
)
return
}

_ = writeSyncText(relativePath: "\(directoryName)/\(lastCrashFileName)", contents: contents)
_ = writeSyncText(
relativePath: "\(directoryName)/crash-native-\(millis)-0.json",
contents: contents
)
}

public static func formatJSStack(_ value: Any?) -> String? {
guard let value else {
return nil
}
if let text = value as? String {
return text
}
if let frames = value as? [[String: Any]] {
let lines = frames.prefix(80).map { frame -> String in
let method = frame["methodName"] as? String ?? "?"
let file = frame["file"] as? String ?? "?"
let line = stringifyFrameNumber(frame["lineNumber"])
let column = stringifyFrameNumber(frame["column"])
return " at \(method) (\(file):\(line):\(column))"
}
return lines.joined(separator: "\n")
}
return String(describing: value)
}

public static func formatExceptionStack(_ exception: NSException) -> String {
let addresses = exception.callStackSymbols
if addresses.isEmpty {
return exception.callStackReturnAddresses.map { String(describing: $0) }.joined(separator: "\n")
}
return addresses.prefix(80).joined(separator: "\n")
}

public static func stringValue(_ value: Any?) -> String? {
guard let value, !(value is NSNull) else {
return nil
}
if let text = value as? String {
return text
}
if JSONSerialization.isValidJSONObject(value),
let data = try? JSONSerialization.data(withJSONObject: value, options: []),
let text = String(data: data, encoding: .utf8) {
return text
}
return String(describing: value)
}

private static func truncate(_ text: String, max: Int) -> String {
guard text.count > max else {
return text
}
let end = text.index(text.startIndex, offsetBy: max)
return String(text[..<end]) + "…"
}

private static func stringifyFrameNumber(_ value: Any?) -> String {
if let number = value as? NSNumber {
return number.stringValue
}
if let intValue = value as? Int {
return String(intValue)
}
if let text = value as? String {
return text
}
return "?"
}
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,21 @@
import ExpoModulesCore
import Foundation

public final class T3NativeControlsModule: Module {
public func definition() -> ModuleDefinition {
Name("T3NativeControls")

// Durable last-chance write for fatal JS records. Expo FileSystem write can
// lose the race with RCTFatal abort; this fsyncs to Documents.
Function("writeSyncText") { (relativePath: String, contents: String) -> Bool in
T3CrashLog.writeSyncText(relativePath: relativePath, contents: contents)
}

// AppDelegate installs the native RCTFatal hooks; this is a no-op ack for JS.
Function("installFatalHandler") { () -> Bool in
true
}

View(T3HeaderButtonView.self) {
Prop("label") { (view: T3HeaderButtonView, label: String) in
view.setLabel(label)
Expand Down
Loading
Loading