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
7 changes: 7 additions & 0 deletions .changeset/fix-formatjs-issue-604.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@swc/plugin-formatjs": patch
---

Avoid hygiene proxy crashes by evaluating only static formatjs descriptor values.
The legacy Rust visitor constructor now ignores its evaluator argument; use
`create_formatjs_visitor_without_evaluator` for new integrations.
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ radix_fmt = "1"
regex = { version = "1.10.4", default-features = false }
rquickjs = "0.8.1"
rustc-hash = "2.1.0"
ryu-js = "1.0.1"
serde = "1.0.203"
serde_json = "1.0.117"
serde_repr = "0.1"
Expand Down
1 change: 0 additions & 1 deletion packages/formatjs/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,4 @@ swc_core = { workspace = true, features = [
"ecma_plugin_transform",
"ecma_ast_serde",
] }
swc_ecma_minifier = { workspace = true }
swc_formatjs_transform = { path = "./transform", version = "31.0.0" }
313 changes: 312 additions & 1 deletion packages/formatjs/__tests__/wasm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@ describe("formatjs swc plugin", () => {
});
`;

expect(transformCode(input)).rejects.toThrow(
await expect(transformCode(input)).rejects.toThrow(
"[React Intl] Messages must be statically evaluate-able for extraction.",
);
});
Expand Down Expand Up @@ -628,6 +628,317 @@ describe("formatjs swc plugin", () => {
expect(code3).toMatch(/id: "Ae\/S0P"/);
});

it("should not evaluate unrelated TypeScript and JSX code (issue #604)", async () => {
const input = `
"use client";

let config: { info: { x: string } | null } = { info: null };

export function setConfig(c: typeof config) {
config = c;
}

function getParams() {
const p: { x?: string } = {};
if (config.info) {
p.x = config.info.x;
}
return p;
}

function run() {
console.log(getParams());
}

export default function Page() {
return <div onClick={() => run()}>click</div>;
}
`;

const output = await transformCode(input, { ast: true });

expect(output).toContain("config.info.x");
expect(output).toContain("onClick");
});

it("should transform several formatMessage calls with ast enabled (issue #604)", async () => {
const input = `
"use client";
import { useIntl, IntlProvider } from "react-intl";

function Messages() {
const intl = useIntl();
return (
<ul>
<li>{intl.formatMessage({ id: "a", defaultMessage: "Message A" })}</li>
<li>{intl.formatMessage({ id: "b", defaultMessage: "Message B" })}</li>
<li>{intl.formatMessage({ id: "c", defaultMessage: "Message C" })}</li>
<li>{intl.formatMessage({ id: "d", defaultMessage: "Message D" })}</li>
<li>{intl.formatMessage({ id: "e", defaultMessage: "Message E" })}</li>
</ul>
);
}

export default function Page() {
return (
<IntlProvider locale="en" messages={{}}>
<Messages />
</IntlProvider>
);
}
`;

const output = await transformCode(input, { ast: true });

expect(output.match(/defaultMessage: \[/g)).toHaveLength(5);
});

it("should evaluate delayed bindings, member lookups, and primitive coercion", async () => {
const input = `
import { defineMessage, formatMessage } from 'react-intl';

function laterBinding() {
return formatMessage({
defaultMessage: MSG,
});
}

const id = "coerced";
const suffix = true;
const messages = {
hello: "Hello from object",
};

defineMessage({
id,
defaultMessage: ("Value " + suffix) as const,
});

formatMessage({
defaultMessage: messages.hello,
});

formatMessage({
defaultMessage: \`Step \${2}\`,
});

formatMessage({
defaultMessage: "Count " + (1 + true),
});

formatMessage({
defaultMessage: \`Negative \${-1}\`,
});

formatMessage({
defaultMessage: "Signed " + -1,
});

formatMessage({
defaultMessage: true ? "Enabled" : "Disabled",
});

formatMessage({
defaultMessage: \`ID \${1e21}\`,
});

const MSG = "Declared later";
`;

const output = await transformCode(input);

expect(output).toMatch(/id: "coerced"/);
expect(output).toContain('"Value true"');
expect(output).toMatch(/defaultMessage: "Hello from object"/);
expect(output).toMatch(/defaultMessage: "Step 2"/);
expect(output).toMatch(/defaultMessage: "Count 2"/);
expect(output).toMatch(/defaultMessage: "Negative -1"/);
expect(output).toMatch(/defaultMessage: "Signed -1"/);
expect(output).toMatch(/defaultMessage: "Enabled"/);
expect(output).toMatch(/defaultMessage: "ID 1e\+21"/);
expect(output).toMatch(/defaultMessage: "Declared later"/);
});

it("should ignore unknown shorthand props without evaluating them", async () => {
const input = `
import { defineMessage } from 'react-intl';

defineMessage({
defaultMessage: "Hello",
metadata,
});
`;

const output = await transformCode(input);

expect(output).toMatch(/defaultMessage: "Hello"/);
expect(output).toContain("metadata");
});

it("should remove shorthand defaultMessage when requested", async () => {
const input = `
import { defineMessage } from 'react-intl';

const defaultMessage = "Hello";

defineMessage({
defaultMessage,
});
`;

const output = await transformCode(input, { removeDefaultMessage: true });

expect(output).toMatch(/defineMessage\(\{\s*id: "[^"]+"\s*\}\)/s);
});

it("should not resolve member values hidden behind later spreads", async () => {
const input = `
import { formatMessage } from 'react-intl';

const runtimeMessages = {};
const messages = {
hello: "Hello",
...runtimeMessages,
};

formatMessage({
defaultMessage: messages.hello,
});
`;

await expect(transformCode(input)).rejects.toThrow(
"[React Intl] Messages must be statically evaluate-able for extraction.",
);
});

it("should not resolve member values hidden behind unknown computed keys", async () => {
const input = `
import { formatMessage } from 'react-intl';

const runtimeKey = globalThis.key;
const messages = {
hello: "Hello",
[runtimeKey]: "Runtime",
};

formatMessage({
defaultMessage: messages.hello,
});
`;

await expect(transformCode(input)).rejects.toThrow(
"[React Intl] Messages must be statically evaluate-able for extraction.",
);
});

it("should treat non-object reassignment as dynamic", async () => {
const input = `
import { formatMessage } from 'react-intl';

let messages = {
hello: "Hello",
};
messages = getMessages();

formatMessage({
defaultMessage: messages.hello,
});
`;

await expect(transformCode(input)).rejects.toThrow(
"[React Intl] Messages must be statically evaluate-able for extraction.",
);
});

it("should preserve statement order for var redeclarations", async () => {
const input = `
import { formatMessage } from 'react-intl';

var MSG = "First";

formatMessage({
defaultMessage: MSG,
});

var MSG = "Second";
`;

const output = await transformCode(input);

expect(output).toMatch(/defaultMessage: "First"/);
expect(output).not.toMatch(/defaultMessage: "Second"/);
});

it("should invalidate object bindings on member writes", async () => {
const input = `
import { formatMessage } from 'react-intl';

const messages = {
hello: "Hello",
};
messages.hello = getMessage();

formatMessage({
defaultMessage: messages.hello,
});
`;

await expect(transformCode(input)).rejects.toThrow(
"[React Intl] Messages must be statically evaluate-able for extraction.",
);
});

it("should precollect bindings inside function bodies", async () => {
const input = `
import { FormattedMessage } from 'react-intl';

function Component() {
const render = () => <FormattedMessage defaultMessage={MSG} />;
const MSG = "Hello from function";

return render();
}
`;

const output = await transformCode(input);

expect(output).toMatch(/id: "[^"]+"/);
expect(output).toMatch(/defaultMessage: MSG/);
});

it("should strip TypeScript wrappers while evaluating descriptor values", async () => {
const input = `
import { defineMessage } from 'react-intl';

defineMessage({
id: "wrapped" as const,
defaultMessage: ("Wrapped message" satisfies string),
});
`;

const output = await transformCode(input);

expect(output).toMatch(/id: "wrapped"/);
expect(output).toMatch(/defaultMessage: "Wrapped message"/);
});

it("should stop resolving cyclic bindings", async () => {
const input = `
import { defineMessage } from 'react-intl';

let a = b;
let b = a;

defineMessage({
defaultMessage: a,
});
`;

await expect(transformCode(input)).rejects.toThrow(
"[React Intl] Messages must be statically evaluate-able for extraction.",
);
});

it("should not error on valid JSX outside formatjs calls (issue #588)", async () => {
// Member expression JSX names like React.Suspense with JSX fallback props
// should not trigger the static evaluation error.
Expand Down
15 changes: 3 additions & 12 deletions packages/formatjs/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,7 @@ use swc_core::{
proxies::TransformPluginProgramMetadata,
},
};
use swc_ecma_minifier::{eval::Evaluator, marks::Marks};
use swc_formatjs_transform::{create_formatjs_visitor, FormatJSPluginOptions};
use swc_formatjs_transform::{create_formatjs_visitor_without_evaluator, FormatJSPluginOptions};

#[plugin_transform]
pub fn process(mut program: Program, metadata: TransformPluginProgramMetadata) -> Program {
Expand All @@ -24,20 +23,12 @@ pub fn process(mut program: Program, metadata: TransformPluginProgramMetadata) -
Default::default()
};

if let Some(module) = program.as_module() {
let evaluator = &mut Evaluator::new(
module.clone(),
Marks {
unresolved_mark: metadata.unresolved_mark,
..Marks::new()
},
);
let mut visitor = create_formatjs_visitor(
if program.as_module().is_some() {
let mut visitor = create_formatjs_visitor_without_evaluator(
std::sync::Arc::new(metadata.source_map),
metadata.comments.as_ref(),
plugin_options,
filename,
evaluator,
);

program.visit_mut_with(&mut visitor);
Expand Down
Loading
Loading