authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-12 18:15:05-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-12 18:15:05-07:00
log5781e2be2b7d079faaf677cc1b39679e9850d833
tree75a210c059f792b137a4b8315d0582ef03ac9a0f
parent2587c8060c103245b4f970b925977008f2592d1d
signature Signed by PGP key B5690EEEBB952194

fix(mobile): stop crashing on launch when a thread has a PR stack (#11486)

Co-authored-by: Claude Code <noreply@anthropic.com>

6 files changed, 137 insertions(+), 2 deletions(-)

oxlint-plugin-t3code/index.ts+2
...@@ -2,6 +2,7 @@ import { definePlugin } from "@oxlint/plugins";...@@ -2,6 +2,7 @@ import { definePlugin } from "@oxlint/plugins";
22
3import namespaceNodeImports from "./rules/namespace-node-imports.ts";3import namespaceNodeImports from "./rules/namespace-node-imports.ts";
4import noGlobalProcessRuntime from "./rules/no-global-process-runtime.ts";4import noGlobalProcessRuntime from "./rules/no-global-process-runtime.ts";
5import noHermesUnsupportedArrayMethods from "./rules/no-hermes-unsupported-array-methods.ts";
5import noInlineSchemaCompile from "./rules/no-inline-schema-compile.ts";6import noInlineSchemaCompile from "./rules/no-inline-schema-compile.ts";
6import noManualEffectRuntimeInTests from "./rules/no-manual-effect-runtime-in-tests.ts";7import noManualEffectRuntimeInTests from "./rules/no-manual-effect-runtime-in-tests.ts";
7import noMobileUniwindThemeEscapeHatches from "./rules/no-mobile-uniwind-theme-escape-hatches.ts";8import noMobileUniwindThemeEscapeHatches from "./rules/no-mobile-uniwind-theme-escape-hatches.ts";
...@@ -14,6 +15,7 @@ export default definePlugin({...@@ -14,6 +15,7 @@ export default definePlugin({
14 rules: {15 rules: {
15 "namespace-node-imports": namespaceNodeImports,16 "namespace-node-imports": namespaceNodeImports,
16 "no-global-process-runtime": noGlobalProcessRuntime,17 "no-global-process-runtime": noGlobalProcessRuntime,
18 "no-hermes-unsupported-array-methods": noHermesUnsupportedArrayMethods,
17 "no-inline-schema-compile": noInlineSchemaCompile,19 "no-inline-schema-compile": noInlineSchemaCompile,
18 "no-manual-effect-runtime-in-tests": noManualEffectRuntimeInTests,20 "no-manual-effect-runtime-in-tests": noManualEffectRuntimeInTests,
19 "no-mobile-uniwind-theme-escape-hatches": noMobileUniwindThemeEscapeHatches,21 "no-mobile-uniwind-theme-escape-hatches": noMobileUniwindThemeEscapeHatches,
oxlint-plugin-t3code/rules/no-hermes-unsupported-array-methods.test.ts created+57
...@@ -0,0 +1,57 @@
1import { assert, describe } from "@effect/vitest";
2
3import { createOxlintRuleHarness } from "../test/utils.ts";
4
5const rule = createOxlintRuleHarness("t3code/no-hermes-unsupported-array-methods", {
6 filename: "fixture.ts",
7});
8
9describe("t3code/no-hermes-unsupported-array-methods", () => {
10 rule.valid("allows in-place sort on a copy", `const sorted = [...items].sort(compare);`);
11
12 rule.valid("allows in-place reverse on a copy", `const reversed = [...items].reverse();`);
13
14 rule.valid(
15 "allows other ES2023 methods Hermes ships",
16 `const last = items.findLast(Boolean); const tail = items.at(-1);`,
17 );
18
19 rule.valid(
20 "ignores property reads that are not calls",
21 `const hasToSorted = typeof Array.prototype.toSorted === "function";`,
22 );
23
24 rule.invalid("reports toSorted", `const sorted = items.toSorted(compare);`, (output) => {
25 assert.match(output, /Array#toSorted/);
26 });
27
28 rule.invalid(
29 "reports toReversed in a chain",
30 `const open = chains.map((chain) => chain.layers.toReversed().filter(isOpen));`,
31 (output) => {
32 assert.match(output, /Array#toReversed/);
33 },
34 );
35
36 rule.invalid(
37 "reports toSpliced via computed access with a copy-then-splice remediation",
38 `const next = items["toSpliced"](0, 1);`,
39 (output) => {
40 assert.match(output, /Array#toSpliced/);
41 assert.match(output, /const copy = \[\.\.\.array\]; copy\.splice\(\.\.\.\); use copy/);
42 },
43 );
44
45 rule.invalid(
46 "reports a static template-literal property name",
47 "const reversed = items[`toReversed`]();",
48 (output) => {
49 assert.match(output, /Array#toReversed/);
50 },
51 );
52
53 rule.valid(
54 "ignores a template-literal property with substitutions",
55 "const value = items[`to${suffix}`]();",
56 );
57});
oxlint-plugin-t3code/rules/no-hermes-unsupported-array-methods.ts created+45
...@@ -0,0 +1,45 @@
1import { defineRule } from "@oxlint/plugins";
2
3// ES2023 change-array-by-copy methods. Hermes does not implement them, and
4// tsconfig targets ESNext, so nothing but this rule stands between a call and a
5// TypeError that is fatal on every mobile launch that reaches it.
6const UNSUPPORTED_METHODS = new Map([
7 ["toSorted", "[...array].sort(...)"],
8 ["toReversed", "[...array].reverse()"],
9 // splice returns the removed elements, so the copy itself is the result.
10 ["toSpliced", "const copy = [...array]; copy.splice(...); use copy"],
11]);
12
13export default defineRule({
14 meta: {
15 type: "problem",
16 docs: {
17 description:
18 "Disallow ES2023 array-by-copy methods (toSorted, toReversed, toSpliced) in code that runs on Hermes.",
19 },
20 },
21 create(context) {
22 return {
23 CallExpression(node) {
24 if (node.callee.type !== "MemberExpression") return;
25 const { property } = node.callee;
26 const name =
27 property.type === "Identifier"
28 ? property.name
29 : property.type === "Literal" && typeof property.value === "string"
30 ? property.value
31 : property.type === "TemplateLiteral" && property.expressions.length === 0
32 ? (property.quasis[0]?.value.cooked ?? null)
33 : null;
34 if (name === null) return;
35 const replacement = UNSUPPORTED_METHODS.get(name);
36 if (replacement === undefined) return;
37
38 context.report({
39 node: property,
40 message: `Hermes does not implement Array#${name}. Copy the array first: ${replacement}.`,
41 });
42 },
43 };
44 },
45});
packages/shared/src/threadPullRequests.test.ts+17-1
...@@ -3,7 +3,7 @@ import {...@@ -3,7 +3,7 @@ import {
3 type ThreadPullRequestLink,3 type ThreadPullRequestLink,
4 type ThreadPullRequestSnapshot,4 type ThreadPullRequestSnapshot,
5} from "@t3tools/contracts";5} from "@t3tools/contracts";
6import { describe, expect, it } from "vite-plus/test";6import { beforeEach, describe, expect, it } from "vite-plus/test";
77
8import {8import {
9 legacyLinkedPullRequestOf,9 legacyLinkedPullRequestOf,
...@@ -15,6 +15,22 @@ import {...@@ -15,6 +15,22 @@ import {
15 threadPullRequestKeysEqual,15 threadPullRequestKeysEqual,
16} from "./threadPullRequests.ts";16} from "./threadPullRequests.ts";
1717
18// Match Hermes: these ES2023 array methods are absent on mobile, and this module runs in
19// the home thread list on every launch.
20beforeEach(() => {
21 const methods = ["toSorted", "toReversed", "toSpliced"] as const;
22 const descriptors = methods.map((method) =>
23 Object.getOwnPropertyDescriptor(Array.prototype, method),
24 );
25 for (const method of methods) Reflect.deleteProperty(Array.prototype, method);
26 return () => {
27 for (const [index, method] of methods.entries()) {
28 const descriptor = descriptors[index];
29 if (descriptor) Reflect.defineProperty(Array.prototype, method, descriptor);
30 }
31 };
32});
33
18function snapshot(input: Partial<ThreadPullRequestSnapshot> = {}): ThreadPullRequestSnapshot {34function snapshot(input: Partial<ThreadPullRequestSnapshot> = {}): ThreadPullRequestSnapshot {
19 return {35 return {
20 state: "open",36 state: "open",
packages/shared/src/threadPullRequests.ts+3-1
...@@ -106,8 +106,10 @@ export function resolveThreadCurrentPullRequest(...@@ -106,8 +106,10 @@ export function resolveThreadCurrentPullRequest(
106 if (open.length === 1) return { kind: "single", link: open[0]! };106 if (open.length === 1) return { kind: "single", link: open[0]! };
107 const chains = resolveThreadPullRequestChains(visible);107 const chains = resolveThreadPullRequestChains(visible);
108 if (open.length > 1) {108 if (open.length > 1) {
109 // `.reverse()` on a copy, not `.toReversed()`: this runs on Hermes, which has no ES2023
110 // array methods, and a TypeError here is fatal on every mobile launch that renders a stack.
109 const openChains = chains111 const openChains = chains
110 .map((chain) => chain.layers.toReversed().filter(isOpen))112 .map((chain) => [...chain.layers].reverse().filter(isOpen))
111 .filter((layers) => layers.length > 0)113 .filter((layers) => layers.length > 0)
112 .sort(114 .sort(
113 (left, right) =>115 (left, right) =>
vite.config.ts+13
...@@ -135,6 +135,19 @@ export default defineConfig({...@@ -135,6 +135,19 @@ export default defineConfig({
135 files: ["apps/mobile/src/**"],135 files: ["apps/mobile/src/**"],
136 rules: { "t3code/no-mobile-uniwind-theme-escape-hatches": "error" },136 rules: { "t3code/no-mobile-uniwind-theme-escape-hatches": "error" },
137 },137 },
138 {
139 // Code that runs on Hermes. It has no ES2023 change-array-by-copy methods, and
140 // tsconfig targets ESNext, so only lint stands between a call and a fatal launch.
141 // Tests run on Node and are exempt.
142 files: [
143 "apps/mobile/src/**",
144 "packages/client-runtime/src/**",
145 "packages/contracts/src/**",
146 "packages/shared/src/**",
147 ],
148 excludeFiles: ["**/*.test.ts", "**/*.test.tsx"],
149 rules: { "t3code/no-hermes-unsupported-array-methods": "error" },
150 },
138 {151 {
139 // Reviewed native and third-party interop boundaries that cannot consume a className.152 // Reviewed native and third-party interop boundaries that cannot consume a className.
140 files: [153 files: [