Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
164 changes: 164 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,164 @@
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