Skip to content
Merged
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
2 changes: 2 additions & 0 deletions oxlint-plugin-t3code/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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}`]();",
);
});
45 changes: 45 additions & 0 deletions oxlint-plugin-t3code/rules/no-hermes-unsupported-array-methods.ts
Original file line number Diff line number Diff line change
@@ -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}.`,
});
},
};
},
});
18 changes: 17 additions & 1 deletion packages/shared/src/threadPullRequests.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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> = {}): ThreadPullRequestSnapshot {
return {
state: "open",
Expand Down
4 changes: 3 additions & 1 deletion packages/shared/src/threadPullRequests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand Down
13 changes: 13 additions & 0 deletions vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down
Loading