diff --git a/graph/javascript/engine/call-edge-generation/jsx.dl b/graph/javascript/engine/call-edge-generation/jsx.dl index 7359d5189..308603127 100644 --- a/graph/javascript/engine/call-edge-generation/jsx.dl +++ b/graph/javascript/engine/call-edge-generation/jsx.dl @@ -26,7 +26,10 @@ // A tag bound to a component WRAPPER (`memo(C)`, `forwardRef(fn)`, `observer(C)`, // `withRouter(C)`) or a LOADER (`lazy(() => import("./x"))`) renders what the wrapper // was handed or what the loader settles to — see "wrapped and lazy components" below. -// Not modelled: `connect(…)(C)` and other curried wrappers. +// Redux's curried `connect(…)(C)` is such a wrapper, and it also hands C the props its +// map functions return; React context hands `useContext(Ctx)` the provider's `value` +// — see "props that come from outside the element" below. Not modelled: other curried +// wrappers. // ============================================================================ // ── the element and what its tag holds ────────────────────────────────────── @@ -67,6 +70,16 @@ jsx_component_loader(w) :- expr_kind(_, "CALL", _, w), call_site("client", _, n, jsx_vue_definer_name("defineComponent"). jsx_vue_definer_name("defineNuxtComponent"). jsx_vue_definer(w) :- expr_kind(_, "CALL", _, w), call_site("client", _, n, _, _, _, w, _, _), jsx_vue_definer_name(n). +// Redux `connect(mapState, mapDispatch)(C)`: the OUTER call is the wrapper, its argument +// 0 the component. Keyed on the callee being a call of `connect`, so a plain +// `connect(db)` or a `connect(…)` whose result is not called again stays out. +jsx_connect_call(w, inner) :- expr_kind(_, "CALL", _, w), expr_child(_, w, "CALLEE", _, inner), + expr_kind(_, "CALL", _, inner), call_site("client", _, "connect", _, _, _, inner, _, _). +jsx_component_wrapper(w) :- jsx_connect_call(w, _). +// React `createContext(…)`: not a wrapper, but it reaches `` and +// `useContext(Ctx)` the ways a component reaches a tag, so jsx_holds follows it too. +// No jsx_call_renders rule reads it: holding a context renders nothing. +jsx_context_create(c) :- expr_kind(_, "CALL", _, c), call_site("client", _, "createContext", _, _, _, c, _, _). // jsx_holds(Expr, Call) — the wrapper or loader call an expression holds, followed the // ways a component reaches a tag: the call itself, a const initialised with it, a named @@ -74,6 +87,7 @@ jsx_vue_definer(w) :- expr_kind(_, "CALL", _, w), call_site("client", _, n, _, _ jsx_holds(w, w) :- jsx_component_wrapper(w). jsx_holds(w, w) :- jsx_component_loader(w). jsx_holds(w, w) :- jsx_vue_definer(w). +jsx_holds(c, c) :- jsx_context_create(c). jsx_holds(e, w) :- expr_binding(_, v, e), jsx_var_holds(v, w). jsx_var_holds(v, w) :- var_init(_, _, e, v), !var_binding_form(_, "OBJECT_PATTERN", v), !var_binding_form(_, "ARRAY_PATTERN", v), jsx_holds(e, w). @@ -147,6 +161,57 @@ param_value(p, "obj", je) :- jsx_tag_value(je, "ctor", t), type_construct_target param_decl(_, _, "0", m, p), !param_is_rest(_, p). prop_value("inst", t, "props", "obj", je) :- jsx_tag_value(je, "ctor", t), type_decl("client", _, _, _, _, t). +// ── props that come from outside the element ──────────────────────────────── +// Two library conventions hand a component values no JSX attribute names, so a +// handler reached through them had no use: `onClick={props.onClickLogout}` and +// `const { logout } = useContext(Ctx); … onClick={logout}` registered nothing. +// +// connect(mapState, mapDispatch)(C): C's props also hold what each map function +// returns, and the object form of mapDispatch (`connect(null, { onSave })`) itself. +// Every returned object is merged into one props value, as the library does. +jsx_connect_props(w, k, i) :- jsx_connect_call(w, inner), call_arg(inner, pos, a), pos <= 1, + expr_value(a, "func", f), return_value(f, k, i), k = "obj". +jsx_connect_props(w, "obj", o) :- jsx_connect_call(w, inner), call_arg(inner, 1, a), expr_value(a, "obj", o). +param_value(p, k, i) :- jsx_connect_props(w, k, i), jsx_call_renders(w, kc, ic), kc != "ctor", callable_value(kc, ic, m), + method_decl("client", _, _, _, _, _, _, m), !method_is_hot(m, _), + param_decl(_, _, "0", m, p), !param_is_rest(_, p). +// A class component reads them as `this.props` in its OWN methods. Not a prop_value of +// the instance: that is inherited (value-flow.dl), and a subclass connected on its own +// (`class Favorites extends Profile`, each with its connect) would read the parent's +// handlers too. +jsx_connect_class_props(t, k, i) :- jsx_connect_props(w, k, i), jsx_call_renders(w, "ctor", t), type_decl("client", _, _, _, _, t). +expr_value(e, k, i) :- expr_kind(_, "PROPERTY_ACCESS", _, e), expr_name(_, "props", e), + expr_child(_, e, "ACCESS_TARGET", _, r), expr_kind(_, "THIS", _, r), expr_owner(_, m, _, r), + this_value(m, "inst", t), jsx_connect_class_props(t, k, i). + +// React context: `useContext(Ctx)` (and React 19's `use(Ctx)`) evaluates to the `value` +// of every `` (React 19: ``) of the same +// createContext call, and to that call's default argument. "Same" is the call the +// argument and the tag hold, followed through consts, imports and re-exports — never +// the name — so two contexts in one app keep their values apart. +jsx_context_tag(je, c) :- jsx_element_tag(je, tag), expr_kind(_, "PROPERTY_ACCESS", _, tag), expr_name(_, "Provider", tag), + expr_child(_, tag, "ACCESS_TARGET", _, r), jsx_holds(r, c), jsx_context_create(c). +jsx_context_tag(je, c) :- jsx_element_tag(je, tag), jsx_holds(tag, c), jsx_context_create(c). +jsx_context_value(c, k, i) :- jsx_context_tag(je, c), prop_value("obj", je, "value", k, i). +jsx_context_value(c, k, i) :- jsx_context_create(c), call_arg(c, 0, a), expr_value(a, k, i). +// The provider's value is usually memoised — `useMemo(() => ({ user, logout }), [user])` +// with its handlers in `useCallback(fn, deps)` — and React's library is not staged, so +// both calls had no value. useMemo evaluates to what its argument 0 returns, +// useCallback to argument 0 itself. +expr_value(u, k, i) :- expr_kind(_, "CALL", _, u), call_site("client", _, "useMemo", _, _, _, u, _, _), + call_arg(u, 0, a), expr_value(a, "func", f), return_value(f, k, i). +expr_value(u, "func", f) :- expr_kind(_, "CALL", _, u), call_site("client", _, "useCallback", _, _, _, u, _, _), + call_arg(u, 0, a), expr_value(a, "func", f). +jsx_context_reader_name("useContext"). +jsx_context_reader_name("use"). +expr_value(u, k, i) :- expr_kind(_, "CALL", _, u), call_site("client", _, n, _, _, _, u, _, _), jsx_context_reader_name(n), + call_arg(u, 0, a), jsx_holds(a, c), jsx_context_create(c), jsx_context_value(c, k, i). +// `{(value) => …}` — the render function receives it. +param_value(p, k, i) :- jsx_element_tag(je, tag), expr_kind(_, "PROPERTY_ACCESS", _, tag), expr_name(_, "Consumer", tag), + expr_child(_, tag, "ACCESS_TARGET", _, r), jsx_holds(r, c), jsx_context_create(c), + prop_value("obj", je, "children", "func", m), method_decl("client", _, _, _, _, _, _, m), + param_decl(_, _, "0", m, p), !param_is_rest(_, p), jsx_context_value(c, k, i). + // ── the rows ──────────────────────────────────────────────────────────────── // The caller is the callable whose body holds the markup; top-level markup // (`root.render()`) belongs to the module initializer, as a top-level call does. diff --git a/graph/javascript/souffle/decls_all.dl b/graph/javascript/souffle/decls_all.dl index a126c63c0..81f190e38 100644 --- a/graph/javascript/souffle/decls_all.dl +++ b/graph/javascript/souffle/decls_all.dl @@ -65,6 +65,13 @@ .decl jsx_call_renders(c0:symbol, c1:symbol, c2:symbol) .decl jsx_loader_settles(c0:symbol, c1:symbol, c2:symbol) .decl jsx_component_kind(c0:symbol) +.decl jsx_connect_call(c0:symbol, c1:symbol) +.decl jsx_connect_props(c0:symbol, c1:symbol, c2:symbol) +.decl jsx_connect_class_props(c0:symbol, c1:symbol, c2:symbol) +.decl jsx_context_create(c0:symbol) +.decl jsx_context_tag(c0:symbol, c1:symbol) +.decl jsx_context_value(c0:symbol, c1:symbol, c2:symbol) +.decl jsx_context_reader_name(c0:symbol) // ── call-edge-generation/calls.dl ── .decl method_prov(c0:symbol, c1:symbol) diff --git a/graph/test/javascript/cases/72-jsx-context-connect-props/src/Auth.jsx b/graph/test/javascript/cases/72-jsx-context-connect-props/src/Auth.jsx new file mode 100644 index 000000000..17944bd84 --- /dev/null +++ b/graph/test/javascript/cases/72-jsx-context-connect-props/src/Auth.jsx @@ -0,0 +1,34 @@ +import { createContext, useCallback, useContext, useMemo } from 'react'; + +export const AuthCtx = createContext(null); +export const ThemeCtx = createContext({ toggle: () => 'default-toggle' }); + +export function AuthProvider({ children }) { + const logout = () => 'out'; + return {children}; +} + +export function ThemeProvider({ children }) { + const toggle = useCallback(() => 'dark', []); + return ({ toggle }), [toggle])}>{children}; +} + +// context in the same file, destructured +export function LogoutButton() { + const { logout } = useContext(AuthCtx); + return ; +} + +// a hook that returns the context value +export const useAuth = () => useContext(AuthCtx); + +// the render-prop consumer +export function LogoutLink() { + return {(auth) => out}; +} + +// CONTROL: another context — its `logout` is not AuthCtx's, only toggle (and the default) are here +export function ThemeButton() { + const { toggle, logout } = useContext(ThemeCtx); + return ; +} diff --git a/graph/test/javascript/cases/72-jsx-context-connect-props/src/Menu.jsx b/graph/test/javascript/cases/72-jsx-context-connect-props/src/Menu.jsx new file mode 100644 index 000000000..2400ce16d --- /dev/null +++ b/graph/test/javascript/cases/72-jsx-context-connect-props/src/Menu.jsx @@ -0,0 +1,7 @@ +import { useAuth } from './Auth'; + +// through an imported hook, member read +export default function Menu() { + const auth = useAuth(); + return
  • out
  • ; +} diff --git a/graph/test/javascript/cases/72-jsx-context-connect-props/src/Plain.jsx b/graph/test/javascript/cases/72-jsx-context-connect-props/src/Plain.jsx new file mode 100644 index 000000000..7e47a9603 --- /dev/null +++ b/graph/test/javascript/cases/72-jsx-context-connect-props/src/Plain.jsx @@ -0,0 +1,9 @@ +import { connect } from './db'; + +// CONTROL: not wrapped by connect(...)(C) — its props come from nobody the graph sees +export function Plain(props) { + return ; +} + +// CONTROL: a one-shot connect(...) that is not curried is not a component wrapper +export const conn = connect({ onClickLogout: () => 'db' }); diff --git a/graph/test/javascript/cases/72-jsx-context-connect-props/src/Profile.jsx b/graph/test/javascript/cases/72-jsx-context-connect-props/src/Profile.jsx new file mode 100644 index 000000000..e3d853136 --- /dev/null +++ b/graph/test/javascript/cases/72-jsx-context-connect-props/src/Profile.jsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { connect } from 'react-redux'; + +function saveProfile() { return { type: 'SAVE' }; } + +// a class component reads this.props; mapDispatch in its object form +class Profile extends React.Component { + render() { + const { onSave } = this.props; + return
    ; + } +} + +export default connect(null, { onSave: saveProfile })(Profile); + +function openFavorites() { return { type: 'OPEN' }; } + +// CONTROL: a subclass connected on its own reads its own props, not Profile's onSave +class Favorites extends Profile { + footer() { + return

    more

    ; + } +} + +export const ConnectedFavorites = connect(null, { onOpen: openFavorites })(Favorites); diff --git a/graph/test/javascript/cases/72-jsx-context-connect-props/src/Settings.jsx b/graph/test/javascript/cases/72-jsx-context-connect-props/src/Settings.jsx new file mode 100644 index 000000000..289236045 --- /dev/null +++ b/graph/test/javascript/cases/72-jsx-context-connect-props/src/Settings.jsx @@ -0,0 +1,12 @@ +import { connect } from 'react-redux'; + +function Settings(props) { + return ; +} + +const mapStateToProps = (state) => ({ user: state.user }); +const mapDispatchToProps = (dispatch) => ({ + onClickLogout: () => dispatch({ type: 'LOGOUT' }), +}); + +export default connect(mapStateToProps, mapDispatchToProps)(Settings); diff --git a/graph/test/javascript/cases/72-jsx-context-connect-props/src/db.js b/graph/test/javascript/cases/72-jsx-context-connect-props/src/db.js new file mode 100644 index 000000000..ac639bfea --- /dev/null +++ b/graph/test/javascript/cases/72-jsx-context-connect-props/src/db.js @@ -0,0 +1 @@ +export function connect(opts) { return opts; } diff --git a/graph/test/javascript/cases/72-jsx-context-connect-props/src/main.jsx b/graph/test/javascript/cases/72-jsx-context-connect-props/src/main.jsx new file mode 100644 index 000000000..941a56789 --- /dev/null +++ b/graph/test/javascript/cases/72-jsx-context-connect-props/src/main.jsx @@ -0,0 +1,16 @@ +import { AuthProvider, ThemeProvider, LogoutButton, LogoutLink, ThemeButton } from './Auth'; +import Menu from './Menu'; +import ConnectedSettings from './Settings'; +import ConnectedProfile from './Profile'; +import { Plain } from './Plain'; + +export function App() { + return ( + + + + + + + ); +} diff --git a/graph/test/javascript/expected/72-jsx-context-connect-props.diag b/graph/test/javascript/expected/72-jsx-context-connect-props.diag new file mode 100644 index 000000000..f3db0ab85 --- /dev/null +++ b/graph/test/javascript/expected/72-jsx-context-connect-props.diag @@ -0,0 +1,26 @@ +import_cause Auth.jsx:1:10 react not_staged +import_cause Auth.jsx:1:25 react not_staged +import_cause Auth.jsx:1:38 react not_staged +import_cause Auth.jsx:1:50 react not_staged +import_cause Profile.jsx:1:1 react not_staged +import_cause Profile.jsx:2:10 react-redux not_staged +import_cause Settings.jsx:1:10 react-redux not_staged +package_entry @axiomcode/code-graph . [] MAIN dist/reason.js NOT_STAGED -> - +unresolved Auth.jsx:12:18 FUNCTION_CALL useCallback callee_untyped +unresolved Auth.jsx:13:27 FUNCTION_CALL useMemo callee_untyped +unresolved Auth.jsx:18:22 FUNCTION_CALL useContext callee_untyped +unresolved Auth.jsx:23:30 FUNCTION_CALL useContext callee_untyped +unresolved Auth.jsx:32:30 FUNCTION_CALL useContext callee_untyped +unresolved Auth.jsx:3:24 FUNCTION_CALL createContext callee_untyped +unresolved Auth.jsx:4:25 FUNCTION_CALL createContext callee_untyped +unresolved Profile.jsx:14:16 FUNCTION_CALL callee_untyped +unresolved Profile.jsx:14:16 FUNCTION_CALL connect callee_untyped +unresolved Profile.jsx:25:35 FUNCTION_CALL callee_untyped +unresolved Profile.jsx:25:35 FUNCTION_CALL connect callee_untyped +unresolved Settings.jsx:12:16 FUNCTION_CALL callee_untyped +unresolved Settings.jsx:12:16 FUNCTION_CALL connect callee_untyped +unresolved Settings.jsx:9:24 FUNCTION_CALL dispatch callee_untyped +value_callee Profile.jsx:14:16 connect(null, { onSave: saveProfile }) expression +value_callee Profile.jsx:25:35 connect(null, { onOpen: openFavorites }) expression +value_callee Settings.jsx:12:16 connect(mapStateToProps, mapDispatchToProps) expression +value_callee Settings.jsx:9:24 dispatch parameter diff --git a/graph/test/javascript/expected/72-jsx-context-connect-props.edges b/graph/test/javascript/expected/72-jsx-context-connect-props.edges new file mode 100644 index 000000000..a9b882a89 --- /dev/null +++ b/graph/test/javascript/expected/72-jsx-context-connect-props.edges @@ -0,0 +1,45 @@ +Auth.jsx:12:18 FUNCTION_CALL useCallback -> ambiguous_unknown - +Auth.jsx:12:18 FUNCTION_CALL useCallback -> callback_registered Auth.jsx:12:30 +Auth.jsx:13:10 JSX_ELEMENT ({ toggle }), [toggle])}>{chi -> ambiguous_unknown - +Auth.jsx:13:27 FUNCTION_CALL useMemo -> ambiguous_unknown - +Auth.jsx:13:27 FUNCTION_CALL useMemo -> callback_registered Auth.jsx:13:35 +Auth.jsx:18:22 FUNCTION_CALL useContext -> ambiguous_unknown - +Auth.jsx:19:18 JSX_ATTRIBUTE onClick={logout} -> callback_registered Auth.jsx:7:18 +Auth.jsx:23:30 FUNCTION_CALL useContext -> ambiguous_unknown - +Auth.jsx:27:10 JSX_ELEMENT {(auth) => out ambiguous_unknown - +Auth.jsx:27:42 JSX_ATTRIBUTE onClick={auth.logout} -> callback_registered Auth.jsx:7:18 +Auth.jsx:32:30 FUNCTION_CALL useContext -> ambiguous_unknown - +Auth.jsx:33:18 JSX_ATTRIBUTE onClick={toggle} -> callback_registered Auth.jsx:12:30 +Auth.jsx:33:18 JSX_ATTRIBUTE onClick={toggle} -> callback_registered Auth.jsx:4:49 +Auth.jsx:3:24 FUNCTION_CALL createContext -> ambiguous_unknown - +Auth.jsx:4:25 FUNCTION_CALL createContext -> ambiguous_unknown - +Auth.jsx:4:25 FUNCTION_CALL createContext -> callback_registered Auth.jsx:4:49 +Auth.jsx:8:10 JSX_ELEMENT {children} ambiguous_unknown - +Menu.jsx:5:16 FUNCTION_CALL useAuth -> known_edge Auth.jsx:23:24 +Menu.jsx:6:14 JSX_ATTRIBUTE onClick={auth.logout} -> callback_registered Auth.jsx:7:18 +Plain.jsx:9:21 FUNCTION_CALL connect -> known_edge db.js:1:1 connect +Profile.jsx:10:18 JSX_ATTRIBUTE onSubmit={onSave} -> callback_registered Profile.jsx:4:1 saveProfile +Profile.jsx:10:44 JSX_ATTRIBUTE onClick={this.props.onSave} -> callback_registered Profile.jsx:4:1 saveProfile +Profile.jsx:14:16 FUNCTION_CALL connect -> ambiguous_unknown - +Profile.jsx:14:16 FUNCTION_CALL connect -> callback_registered Profile.jsx:4:1 saveProfile +Profile.jsx:14:16 FUNCTION_CALL connect(null, { onSave: saveProfile }) -> ambiguous_unknown - +Profile.jsx:21:15 JSX_ATTRIBUTE onClick={this.props.onOpen} -> callback_registered Profile.jsx:16:1 openFavorites +Profile.jsx:25:35 FUNCTION_CALL connect -> ambiguous_unknown - +Profile.jsx:25:35 FUNCTION_CALL connect -> callback_registered Profile.jsx:16:1 openFavorites +Profile.jsx:25:35 FUNCTION_CALL connect(null, { onOpen: openFavorites }) -> ambiguous_unknown - +Settings.jsx:12:16 FUNCTION_CALL connect -> ambiguous_unknown - +Settings.jsx:12:16 FUNCTION_CALL connect -> callback_registered Settings.jsx:7:25 +Settings.jsx:12:16 FUNCTION_CALL connect -> callback_registered Settings.jsx:8:28 +Settings.jsx:12:16 FUNCTION_CALL connect(mapStateToProps, mapDispatchToProps) -> ambiguous_unknown - +Settings.jsx:12:16 FUNCTION_CALL connect(mapStateToProps, mapDispatchToProps) -> callback_registered Settings.jsx:3:1 Settings +Settings.jsx:4:18 JSX_ATTRIBUTE onClick={props.onClickLogout} -> callback_registered Settings.jsx:9:18 +Settings.jsx:9:24 FUNCTION_CALL dispatch -> ambiguous_unknown - +main.jsx:10:7 JSX_ELEMENT \n known_edge Auth.jsx:11:1 ThemeProvider +main.jsx:11:25 JSX_ELEMENT -> known_edge Auth.jsx:26:1 LogoutLink +main.jsx:11:39 JSX_ELEMENT -> known_edge Auth.jsx:31:1 ThemeButton +main.jsx:11:54 JSX_ELEMENT -> known_edge Menu.jsx:4:1 Menu +main.jsx:11:9 JSX_ELEMENT -> known_edge Auth.jsx:17:1 LogoutButton +main.jsx:12:30 JSX_ELEMENT -> known_edge Profile.jsx:8:3 render +main.jsx:12:50 JSX_ELEMENT -> known_edge Plain.jsx:4:1 Plain +main.jsx:12:9 JSX_ELEMENT -> known_edge Settings.jsx:3:1 Settings +main.jsx:9:5 JSX_ELEMENT \n \n known_edge Auth.jsx:6:1 AuthProvider diff --git a/graph/test/javascript/expected/72-jsx-context-connect-props.oracle b/graph/test/javascript/expected/72-jsx-context-connect-props.oracle new file mode 100644 index 000000000..b3a2dc1f1 --- /dev/null +++ b/graph/test/javascript/expected/72-jsx-context-connect-props.oracle @@ -0,0 +1,3 @@ +Menu.jsx:5:16 FUNCTION_CALL useAuth EXACT Auth.jsx:23:24 +Plain.jsx:9:21 FUNCTION_CALL connect EXACT db.js:1:1 +# defects: 0