diff --git a/oxlint-plugin-t3code/index.ts b/oxlint-plugin-t3code/index.ts index 5037dc94341372fbc28b70855171ee05a9cce65d..a2d1997e43f9defe1233de5d69bb0879fe6d089e 100644 --- a/oxlint-plugin-t3code/index.ts +++ b/oxlint-plugin-t3code/index.ts @@ -2,6 +2,7 @@ import { definePlugin } from "@oxlint/plugins"; import namespaceNodeImports from "./rules/namespace-node-imports.ts"; import noGlobalProcessRuntime from "./rules/no-global-process-runtime.ts"; +import noHermesUnsupportedArrayMethods from "./rules/no-hermes-unsupported-array-methods.ts"; import noInlineSchemaCompile from "./rules/no-inline-schema-compile.ts"; import noManualEffectRuntimeInTests from "./rules/no-manual-effect-runtime-in-tests.ts"; import noMobileUniwindThemeEscapeHatches from "./rules/no-mobile-uniwind-theme-escape-hatches.ts"; @@ -14,6 +15,7 @@ export default definePlugin({ rules: { "namespace-node-imports": namespaceNodeImports, "no-global-process-runtime": noGlobalProcessRuntime, + "no-hermes-unsupported-array-methods": noHermesUnsupportedArrayMethods, "no-inline-schema-compile": noInlineSchemaCompile, "no-manual-effect-runtime-in-tests": noManualEffectRuntimeInTests, "no-mobile-uniwind-theme-escape-hatches": noMobileUniwindThemeEscapeHatches, diff --git a/oxlint-plugin-t3code/rules/no-hermes-unsupported-array-methods.test.ts b/oxlint-plugin-t3code/rules/no-hermes-unsupported-array-methods.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..4bd751c13c9a3eefe46c49e65cfadb6e136c8594 --- /dev/null +++ b/oxlint-plugin-t3code/rules/no-hermes-unsupported-array-methods.test.ts @@ -0,0 +1,57 @@ +import { assert, describe } from "@effect/vitest"; + +import { createOxlintRuleHarness } from "../test/utils.ts"; + +const rule = createOxlintRuleHarness("t3code/no-hermes-unsupported-array-methods", { + filename: "fixture.ts", +}); + +describe("t3code/no-hermes-unsupported-array-methods", () => { + rule.valid("allows in-place sort on a copy", `const sorted = [...items].sort(compare);`); + + rule.valid("allows in-place reverse on a copy", `const reversed = [...items].reverse();`); + + rule.valid( + "allows other ES2023 methods Hermes ships", + `const last = items.findLast(Boolean); const tail = items.at(-1);`, + ); + + rule.valid( + "ignores property reads that are not calls", + `const hasToSorted = typeof Array.prototype.toSorted === "function";`, + ); + + rule.invalid("reports toSorted", `const sorted = items.toSorted(compare);`, (output) => { + assert.match(output, /Array#toSorted/); + }); + + rule.invalid( + "reports toReversed in a chain", + `const open = chains.map((chain) => chain.layers.toReversed().filter(isOpen));`, + (output) => { + assert.match(output, /Array#toReversed/); + }, + ); + + rule.invalid( + "reports toSpliced via computed access with a copy-then-splice remediation", + `const next = items["toSpliced"](0, 1);`, + (output) => { + assert.match(output, /Array#toSpliced/); + assert.match(output, /const copy = \[\.\.\.array\]; copy\.splice\(\.\.\.\); use copy/); + }, + ); + + rule.invalid( + "reports a static template-literal property name", + "const reversed = items[`toReversed`]();", + (output) => { + assert.match(output, /Array#toReversed/); + }, + ); + + rule.valid( + "ignores a template-literal property with substitutions", + "const value = items[`to${suffix}`]();", + ); +}); diff --git a/oxlint-plugin-t3code/rules/no-hermes-unsupported-array-methods.ts b/oxlint-plugin-t3code/rules/no-hermes-unsupported-array-methods.ts new file mode 100644 index 0000000000000000000000000000000000000000..e5dd8867e69647e9a9608059ae74a5a662fe356b --- /dev/null +++ b/oxlint-plugin-t3code/rules/no-hermes-unsupported-array-methods.ts @@ -0,0 +1,45 @@ +import { defineRule } from "@oxlint/plugins"; + +// ES2023 change-array-by-copy methods. Hermes does not implement them, and +// tsconfig targets ESNext, so nothing but this rule stands between a call and a +// TypeError that is fatal on every mobile launch that reaches it. +const UNSUPPORTED_METHODS = new Map([ + ["toSorted", "[...array].sort(...)"], + ["toReversed", "[...array].reverse()"], + // splice returns the removed elements, so the copy itself is the result. + ["toSpliced", "const copy = [...array]; copy.splice(...); use copy"], +]); + +export default defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow ES2023 array-by-copy methods (toSorted, toReversed, toSpliced) in code that runs on Hermes.", + }, + }, + create(context) { + return { + CallExpression(node) { + if (node.callee.type !== "MemberExpression") return; + const { property } = node.callee; + const name = + property.type === "Identifier" + ? property.name + : property.type === "Literal" && typeof property.value === "string" + ? property.value + : property.type === "TemplateLiteral" && property.expressions.length === 0 + ? (property.quasis[0]?.value.cooked ?? null) + : null; + if (name === null) return; + const replacement = UNSUPPORTED_METHODS.get(name); + if (replacement === undefined) return; + + context.report({ + node: property, + message: `Hermes does not implement Array#${name}. Copy the array first: ${replacement}.`, + }); + }, + }; + }, +}); diff --git a/packages/shared/src/threadPullRequests.test.ts b/packages/shared/src/threadPullRequests.test.ts index 05fc7b12b021e9101b1b069fa950fe7dc325a0db..6f231f28989b3b4ea044adb94cd213ed1a2769a4 100644 --- a/packages/shared/src/threadPullRequests.test.ts +++ b/packages/shared/src/threadPullRequests.test.ts @@ -3,7 +3,7 @@ import { type ThreadPullRequestLink, type ThreadPullRequestSnapshot, } from "@t3tools/contracts"; -import { describe, expect, it } from "vite-plus/test"; +import { beforeEach, describe, expect, it } from "vite-plus/test"; import { legacyLinkedPullRequestOf, @@ -15,6 +15,22 @@ import { threadPullRequestKeysEqual, } from "./threadPullRequests.ts"; +// Match Hermes: these ES2023 array methods are absent on mobile, and this module runs in +// the home thread list on every launch. +beforeEach(() => { + const methods = ["toSorted", "toReversed", "toSpliced"] as const; + const descriptors = methods.map((method) => + Object.getOwnPropertyDescriptor(Array.prototype, method), + ); + for (const method of methods) Reflect.deleteProperty(Array.prototype, method); + return () => { + for (const [index, method] of methods.entries()) { + const descriptor = descriptors[index]; + if (descriptor) Reflect.defineProperty(Array.prototype, method, descriptor); + } + }; +}); + function snapshot(input: Partial = {}): ThreadPullRequestSnapshot { return { state: "open", diff --git a/packages/shared/src/threadPullRequests.ts b/packages/shared/src/threadPullRequests.ts index 5d91d98d52c99b4e8c83e4ef7d6a1fc96ef8bd9a..55e23027832da3dc1e7fa39754e2dfe91fa14140 100644 --- a/packages/shared/src/threadPullRequests.ts +++ b/packages/shared/src/threadPullRequests.ts @@ -106,8 +106,10 @@ export function resolveThreadCurrentPullRequest( if (open.length === 1) return { kind: "single", link: open[0]! }; const chains = resolveThreadPullRequestChains(visible); if (open.length > 1) { + // `.reverse()` on a copy, not `.toReversed()`: this runs on Hermes, which has no ES2023 + // array methods, and a TypeError here is fatal on every mobile launch that renders a stack. const openChains = chains - .map((chain) => chain.layers.toReversed().filter(isOpen)) + .map((chain) => [...chain.layers].reverse().filter(isOpen)) .filter((layers) => layers.length > 0) .sort( (left, right) => diff --git a/vite.config.ts b/vite.config.ts index e26d5d6e653e25e49480944a7eb201346b7752d4..b9f2c9cc2c4b335c607dc59bbe5ac60c6393e29e 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -135,6 +135,19 @@ export default defineConfig({ files: ["apps/mobile/src/**"], rules: { "t3code/no-mobile-uniwind-theme-escape-hatches": "error" }, }, + { + // Code that runs on Hermes. It has no ES2023 change-array-by-copy methods, and + // tsconfig targets ESNext, so only lint stands between a call and a fatal launch. + // Tests run on Node and are exempt. + files: [ + "apps/mobile/src/**", + "packages/client-runtime/src/**", + "packages/contracts/src/**", + "packages/shared/src/**", + ], + excludeFiles: ["**/*.test.ts", "**/*.test.tsx"], + rules: { "t3code/no-hermes-unsupported-array-methods": "error" }, + }, { // Reviewed native and third-party interop boundaries that cannot consume a className. files: [